import * as plugins from '../plugins.js'; import { GatewayClientDoc, type IGatewayClientPendingProvisioning, } from '../db/index.js'; import type { IGatewayClient } from '../../ts_interfaces/data/workhoster.js'; import type { IGatewayCredentialLifecycle } from '../../ts_interfaces/data/route-management.js'; export interface IStagedGatewayClientProvisioning { action: plugins.servezoneInterfaces.data.TGatewayClientProvisioningAction; gatewayClient: IGatewayClient; credentialSequence: number; policyDigest: string; } interface INormalizedGatewayClientSpec { type: IGatewayClient['type']; name: string; description: string | null; hostnamePatterns: string[]; allowedRouteTargets: IGatewayClient['allowedRouteTargets']; capabilities: IGatewayClient['capabilities']; enabled: boolean; } const defaultCapabilities: IGatewayClient['capabilities'] = { readDomains: true, readDnsRecords: true, readRoutes: true, syncRoutes: true, syncDnsRecords: false, readMail: false, manageMail: false, readCertificates: false, requestCertificates: false, readWebPush: false, manageWebPush: false, }; export class GatewayClientManager { public async initialize(): Promise {} public async listClients(): Promise { const docs = await GatewayClientDoc.findAll(); return docs.map((doc) => this.toPublicClient(doc)); } public async getClient(id: string): Promise { const doc = await GatewayClientDoc.findById(id); return doc ? this.toPublicClient(doc) : null; } public async createClient(options: { id?: string; type: IGatewayClient['type']; name: string; description?: string; hostnamePatterns?: string[]; allowedRouteTargets?: IGatewayClient['allowedRouteTargets']; capabilities?: IGatewayClient['capabilities']; createdBy: string; applyCapabilityDefaults?: boolean; }): Promise { const id = this.normalizeId(options.id || `${options.type}-${plugins.uuid.v4()}`); if (!id) { throw new Error('gateway client id is required'); } if (await GatewayClientDoc.findById(id)) { throw new Error('gateway client already exists'); } const now = Date.now(); const doc = new GatewayClientDoc(); doc.id = id; doc.type = options.type; doc.name = options.name.trim(); doc.description = options.description?.trim() || undefined; doc.hostnamePatterns = this.normalizeHostnamePatterns(options.hostnamePatterns || []); doc.allowedRouteTargets = this.normalizeAllowedRouteTargets(options.allowedRouteTargets || []); doc.capabilities = options.applyCapabilityDefaults === false ? { ...(options.capabilities || {}) } : { ...defaultCapabilities, ...(options.capabilities || {}) }; doc.enabled = true; doc.policyGeneration = 1; doc.credentialSequence = 0; doc.activeCredentialSequence = 0; doc.mutationRevision = 1; doc.pendingProvisioning = null; doc.createdAt = now; doc.updatedAt = now; doc.createdBy = options.createdBy; await doc.save(); return this.toPublicClient(doc); } public async updateClient( id: string, patch: Partial>, options: { applyCapabilityDefaults?: boolean } = {}, ): Promise { const doc = await GatewayClientDoc.findById(id); if (!doc) return null; const expectedRevision = this.normalizeMutationRevision(doc.mutationRevision); const priorPolicy = JSON.stringify({ hostnamePatterns: doc.hostnamePatterns || [], allowedRouteTargets: doc.allowedRouteTargets || [], capabilities: doc.capabilities || {}, enabled: doc.enabled, }); const nextName = patch.name !== undefined ? patch.name.trim() : doc.name; const nextDescription = patch.description !== undefined ? patch.description.trim() || undefined : doc.description; const nextHostnamePatterns = patch.hostnamePatterns !== undefined ? this.normalizeHostnamePatterns(patch.hostnamePatterns) : doc.hostnamePatterns || []; const nextAllowedRouteTargets = patch.allowedRouteTargets !== undefined ? this.normalizeAllowedRouteTargets(patch.allowedRouteTargets) : doc.allowedRouteTargets || []; const nextCapabilities = patch.capabilities !== undefined ? options.applyCapabilityDefaults === false ? { ...patch.capabilities } : { ...defaultCapabilities, ...patch.capabilities } : doc.capabilities || {}; const nextEnabled = patch.enabled !== undefined ? patch.enabled : doc.enabled; const nextPolicy = JSON.stringify({ hostnamePatterns: nextHostnamePatterns, allowedRouteTargets: nextAllowedRouteTargets, capabilities: nextCapabilities, enabled: nextEnabled, }); const policyGeneration = this.normalizePolicyGeneration(doc.policyGeneration) + (priorPolicy === nextPolicy ? 0 : 1); const updatedAt = Date.now(); const updated = await GatewayClientDoc.compareAndSet(id, expectedRevision, { name: nextName, hostnamePatterns: structuredClone(nextHostnamePatterns), allowedRouteTargets: structuredClone(nextAllowedRouteTargets), capabilities: structuredClone(nextCapabilities), enabled: nextEnabled, policyGeneration, pendingProvisioning: null, updatedAt, ...(nextDescription ? { description: nextDescription } : {}), }, nextDescription ? [] : ['description']); if (!updated) { if (await GatewayClientDoc.findById(id)) throw new Error('Gateway client changed concurrently; retry the update'); return null; } return this.toPublicClient(updated); } public async deleteClient(id: string): Promise { const doc = await GatewayClientDoc.findById(id); if (!doc) return false; const deleted = await GatewayClientDoc.deleteIfRevision( id, this.normalizeMutationRevision(doc.mutationRevision), ); if (!deleted && await GatewayClientDoc.findById(id)) { throw new Error('Gateway client changed concurrently; retry the deletion'); } return deleted; } public async upsertClient( spec: plugins.servezoneInterfaces.data.IGatewayClientProvisioningSpec, createdBy: string, ): Promise<{ action: plugins.servezoneInterfaces.data.TGatewayClientProvisioningAction; gatewayClient: IGatewayClient; }> { const id = this.normalizeId(spec.id); if (!id) throw new Error('gateway client id is required'); const normalized = { type: spec.type, name: spec.name.trim(), description: spec.description?.trim() || undefined, hostnamePatterns: this.normalizeHostnamePatterns(spec.hostnamePatterns || []), allowedRouteTargets: this.normalizeAllowedRouteTargets(spec.allowedRouteTargets || []), capabilities: { ...(spec.capabilities || {}) }, enabled: spec.enabled ?? true, }; if (!normalized.name) throw new Error('gateway client name is required'); const existing = await GatewayClientDoc.findById(id); if (!existing) { const gatewayClient = await this.createClient({ id, type: normalized.type, name: normalized.name, description: normalized.description, hostnamePatterns: normalized.hostnamePatterns, allowedRouteTargets: normalized.allowedRouteTargets, capabilities: normalized.capabilities, createdBy, applyCapabilityDefaults: false, }); if (!normalized.enabled) { return { action: 'created', gatewayClient: (await this.updateClient(id, { enabled: false }))!, }; } return { action: 'created', gatewayClient }; } if (existing.type !== normalized.type) { throw new Error('gateway client type cannot be changed'); } const current = { type: existing.type, name: existing.name, description: existing.description?.trim() || undefined, hostnamePatterns: this.normalizeHostnamePatterns(existing.hostnamePatterns || []), allowedRouteTargets: this.normalizeAllowedRouteTargets(existing.allowedRouteTargets || []), capabilities: { ...(existing.capabilities || {}) }, enabled: existing.enabled, }; if (JSON.stringify(current) === JSON.stringify(normalized)) { return { action: 'unchanged', gatewayClient: this.toPublicClient(existing) }; } const gatewayClient = await this.updateClient(id, normalized, { applyCapabilityDefaults: false }); if (!gatewayClient) throw new Error('gateway client disappeared during provisioning'); return { action: 'updated', gatewayClient }; } /** * Stage an idempotent provisioning policy without changing the live policy. * Sequence gaps after a later credential write failure are intentional. */ public async stageClientProvisioning( spec: plugins.servezoneInterfaces.data.IGatewayClientProvisioningSpec, stagedBy: string, ): Promise { const id = this.normalizeId(spec.id); if (!id) throw new Error('gateway client id is required'); const normalized = this.normalizeProvisioningSpec(spec); const existing = await GatewayClientDoc.findById(id); const now = Date.now(); if (!existing) { const doc = new GatewayClientDoc(); doc.id = id; doc.type = normalized.type; doc.name = normalized.name; doc.description = normalized.description || undefined; doc.hostnamePatterns = normalized.hostnamePatterns; doc.allowedRouteTargets = normalized.allowedRouteTargets; doc.capabilities = normalized.capabilities; doc.enabled = normalized.enabled; doc.policyGeneration = 1; doc.credentialSequence = 1; doc.activeCredentialSequence = 0; doc.mutationRevision = 1; doc.createdAt = now; doc.updatedAt = now; doc.createdBy = stagedBy; const policyDigest = this.digestPolicy(id, normalized, 1); doc.pendingProvisioning = { ...structuredClone(normalized), policyGeneration: 1, policyDigest, credentialSequence: 1, stagedAt: now, stagedBy, }; await doc.save(); return { action: 'created', gatewayClient: this.toPublicPendingClient(doc, doc.pendingProvisioning), credentialSequence: 1, policyDigest, }; } if (existing.type !== normalized.type) { throw new Error('gateway client type cannot be changed'); } const active = this.activePolicyFromDoc(existing); const activeGeneration = this.normalizePolicyGeneration(existing.policyGeneration); const credentialSequence = this.normalizeCredentialSequence(existing.credentialSequence) + 1; const policyUnchanged = this.digestPolicy(id, active, activeGeneration) === this.digestPolicy(id, normalized, activeGeneration); const metadataChanged = active.name !== normalized.name || active.description !== normalized.description; const policyGeneration = policyUnchanged ? activeGeneration : activeGeneration + 1; const policyDigest = this.digestPolicy(id, normalized, policyGeneration); const pendingProvisioning: IGatewayClientPendingProvisioning = { ...structuredClone(normalized), policyGeneration, policyDigest, credentialSequence, stagedAt: now, stagedBy, }; const set: Record = { credentialSequence, pendingProvisioning, updatedAt: now, }; const unset: string[] = []; if (policyUnchanged) { set.name = normalized.name; if (normalized.description) { set.description = normalized.description; } else { unset.push('description'); } } const updated = await GatewayClientDoc.compareAndSet( id, this.normalizeMutationRevision(existing.mutationRevision), set, unset, ); if (!updated) throw new Error('Gateway client changed concurrently; retry provisioning'); return { action: policyUnchanged && !metadataChanged ? 'unchanged' : 'updated', gatewayClient: this.toPublicPendingClient(updated, updated.pendingProvisioning!), credentialSequence, policyDigest, }; } /** Resolve the exact durable policy a provisioned credential was issued for. */ public async getClientForProvisionedCredential( idArg: string, typeArg: IGatewayClient['type'], lifecycleArg: IGatewayCredentialLifecycle, ): Promise { const doc = await GatewayClientDoc.findById(idArg); if (!doc || doc.type !== typeArg) return null; const activeGeneration = this.normalizePolicyGeneration(doc.policyGeneration); if ( lifecycleArg.state === 'active' && lifecycleArg.sequence === this.normalizeCredentialSequence(doc.activeCredentialSequence) && lifecycleArg.policyGeneration === activeGeneration && lifecycleArg.policyDigest === this.digestPolicy(doc.id, this.activePolicyFromDoc(doc), activeGeneration) ) { return this.toPublicClient(doc); } const pending = doc.pendingProvisioning; if ( lifecycleArg.state === 'candidate' && pending && this.normalizeCredentialSequence(doc.credentialSequence) === lifecycleArg.sequence && pending.credentialSequence === lifecycleArg.sequence && pending.policyGeneration === lifecycleArg.policyGeneration && pending.policyDigest === lifecycleArg.policyDigest ) { return this.toPublicPendingClient(doc, pending); } return null; } /** Promote only the pending policy owned by this exact credential sequence. */ public async promoteProvisionedPolicy( idArg: string, typeArg: IGatewayClient['type'], lifecycleArg: IGatewayCredentialLifecycle, ): Promise { const doc = await GatewayClientDoc.findById(idArg); if (!doc || doc.type !== typeArg) { throw new Error('Gateway client is missing or has the wrong type'); } const expectedRevision = this.normalizeMutationRevision(doc.mutationRevision); const activeGeneration = this.normalizePolicyGeneration(doc.policyGeneration); const activeDigest = this.digestPolicy(doc.id, this.activePolicyFromDoc(doc), activeGeneration); if ( lifecycleArg.state === 'active' && lifecycleArg.sequence === this.normalizeCredentialSequence(doc.activeCredentialSequence) && lifecycleArg.policyGeneration === activeGeneration && lifecycleArg.policyDigest === activeDigest ) { return this.toPublicClient(doc); } const pending = doc.pendingProvisioning; if ( (lifecycleArg.state !== 'candidate' && lifecycleArg.state !== 'active') || !pending || this.normalizeCredentialSequence(doc.credentialSequence) !== lifecycleArg.sequence || pending.credentialSequence !== lifecycleArg.sequence || pending.policyGeneration !== lifecycleArg.policyGeneration || pending.policyDigest !== lifecycleArg.policyDigest ) { throw new Error('Gateway credential policy was superseded before finalization'); } const updated = await GatewayClientDoc.compareAndSet(idArg, expectedRevision, { name: pending.name, hostnamePatterns: structuredClone(pending.hostnamePatterns), allowedRouteTargets: structuredClone(pending.allowedRouteTargets), capabilities: structuredClone(pending.capabilities), enabled: pending.enabled, policyGeneration: pending.policyGeneration, activeCredentialSequence: pending.credentialSequence, pendingProvisioning: null, updatedAt: Date.now(), ...(pending.description ? { description: pending.description } : {}), }, pending.description ? [] : ['description']); if (!updated) { throw new Error('Gateway client changed concurrently; retry credential finalization'); } return this.toPublicClient(updated); } public digestPublicClientPolicy(clientArg: IGatewayClient): string { return this.digestPolicy( clientArg.id, { type: clientArg.type, name: clientArg.name, description: clientArg.description || null, hostnamePatterns: structuredClone(clientArg.hostnamePatterns), allowedRouteTargets: structuredClone(clientArg.allowedRouteTargets), capabilities: structuredClone(clientArg.capabilities), enabled: clientArg.enabled, }, clientArg.policyGeneration, ); } private normalizeId(id: string): string { return id.trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); } private normalizeHostnamePatterns(values: string[]): string[] { return Array.from(new Set(values.map((value) => this.normalizeHostnamePattern(value)))); } private normalizeHostnamePattern(valueArg: string): string { const value = valueArg.trim().toLowerCase().replace(/\.+$/, ''); if (value === '*') return value; const wildcard = value.startsWith('*.'); const hostname = wildcard ? value.slice(2) : value; const ascii = plugins.url.domainToASCII(hostname); if (!ascii || ascii.includes('*')) throw new Error(`invalid hostname pattern: ${valueArg}`); return wildcard ? `*.${ascii}` : ascii; } private normalizeAllowedRouteTargets(targets: IGatewayClient['allowedRouteTargets']): IGatewayClient['allowedRouteTargets'] { return targets .map((target) => ({ host: this.normalizeTargetHost(target.host), ports: Array.from(new Set(target.ports.filter((port) => Number.isInteger(port) && port > 0 && port <= 65535))).sort((a, b) => a - b), ...(target.allowAnyPort ? { allowAnyPort: true } : {}), })) .filter((target) => target.host && (target.allowAnyPort || target.ports.length > 0)); } private normalizeTargetHost(valueArg: string): string { const value = valueArg.trim().toLowerCase().replace(/\.+$/, ''); if (plugins.net.isIP(value)) return value; const ascii = plugins.url.domainToASCII(value); if (!ascii) throw new Error(`invalid route target host: ${valueArg}`); return ascii; } private normalizeProvisioningSpec( specArg: plugins.servezoneInterfaces.data.IGatewayClientProvisioningSpec, ): INormalizedGatewayClientSpec { const normalized: INormalizedGatewayClientSpec = { type: specArg.type, name: specArg.name.trim(), description: specArg.description?.trim() || null, hostnamePatterns: this.normalizeHostnamePatterns(specArg.hostnamePatterns || []), allowedRouteTargets: this.normalizeAllowedRouteTargets(specArg.allowedRouteTargets || []), capabilities: { ...(specArg.capabilities || {}) }, enabled: specArg.enabled ?? true, }; if (!normalized.name) throw new Error('gateway client name is required'); return normalized; } private activePolicyFromDoc(docArg: GatewayClientDoc): INormalizedGatewayClientSpec { return { type: docArg.type, name: docArg.name, description: docArg.description?.trim() || null, hostnamePatterns: this.normalizeHostnamePatterns(docArg.hostnamePatterns || []), allowedRouteTargets: this.normalizeAllowedRouteTargets(docArg.allowedRouteTargets || []), capabilities: { ...(docArg.capabilities || {}) }, enabled: docArg.enabled, }; } private digestPolicy( idArg: string, policyArg: INormalizedGatewayClientSpec, generationArg: number, ): string { const canonicalize = (valueArg: unknown): unknown => { if (Array.isArray(valueArg)) return valueArg.map((entryArg) => canonicalize(entryArg)); if (valueArg && typeof valueArg === 'object') { return Object.fromEntries(Object.keys(valueArg as Record) .sort((left, right) => left.localeCompare(right)) .map((keyArg) => [keyArg, canonicalize((valueArg as Record)[keyArg])])); } return valueArg; }; return plugins.crypto.createHash('sha256').update(JSON.stringify(canonicalize({ id: idArg, generation: generationArg, policy: { type: policyArg.type, hostnamePatterns: policyArg.hostnamePatterns, allowedRouteTargets: policyArg.allowedRouteTargets, capabilities: policyArg.capabilities, enabled: policyArg.enabled, }, }))).digest('hex'); } private normalizeCredentialSequence(valueArg: unknown): number { return Number.isSafeInteger(valueArg) && Number(valueArg) >= 0 ? Number(valueArg) : 0; } private normalizePolicyGeneration(valueArg: unknown): number { return Number.isSafeInteger(valueArg) && Number(valueArg) >= 1 ? Number(valueArg) : 1; } private normalizeMutationRevision(valueArg: unknown): number { return Number.isSafeInteger(valueArg) && Number(valueArg) >= 0 ? Number(valueArg) : 0; } private toPublicPendingClient( docArg: GatewayClientDoc, pendingArg: IGatewayClientPendingProvisioning, ): IGatewayClient { return { id: docArg.id, type: pendingArg.type, name: pendingArg.name, description: pendingArg.description || undefined, hostnamePatterns: structuredClone(pendingArg.hostnamePatterns), allowedRouteTargets: structuredClone(pendingArg.allowedRouteTargets), capabilities: structuredClone(pendingArg.capabilities), enabled: pendingArg.enabled, policyGeneration: pendingArg.policyGeneration, createdAt: docArg.createdAt, updatedAt: pendingArg.stagedAt, createdBy: docArg.createdBy, }; } private toPublicClient(doc: GatewayClientDoc): IGatewayClient { return { id: doc.id, type: doc.type, name: doc.name, description: doc.description, hostnamePatterns: doc.hostnamePatterns || [], allowedRouteTargets: doc.allowedRouteTargets || [], capabilities: doc.capabilities || {}, enabled: doc.enabled, policyGeneration: this.normalizePolicyGeneration(doc.policyGeneration), createdAt: doc.createdAt, updatedAt: doc.updatedAt, createdBy: doc.createdBy, }; } }