| 1 | import * as connectivity from "@distilled.cloud/cloudflare/connectivity"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Option from "effect/Option"; |
| 4 | import * as Predicate from "effect/Predicate"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | |
| 7 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 8 | import { isResolved } from "../../Diff.ts"; |
| 9 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 10 | import * as Provider from "../../Provider.ts"; |
| 11 | import { Resource } from "../../Resource.ts"; |
| 12 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 13 | import type { Providers } from "../Providers.ts"; |
| 14 | |
| 15 | const TypeId = "Cloudflare.Connectivity.DirectoryService" as const; |
| 16 | type TypeId = typeof TypeId; |
| 17 | |
| 18 | /** |
| 19 | * Protocol of a Connectivity Directory service. |
| 20 | */ |
| 21 | export type DirectoryServiceType = "tcp" | "http"; |
| 22 | |
| 23 | /** |
| 24 | * Application protocol hint for `tcp` services. |
| 25 | */ |
| 26 | export type DirectoryServiceAppProtocol = |
| 27 | | "postgresql" |
| 28 | | "mysql" |
| 29 | | (string & {}); |
| 30 | |
| 31 | export declare namespace DirectoryService { |
| 32 | /** |
| 33 | * Network a private IP host is reachable through — identified by the |
| 34 | * Cloudflare Tunnel that carries the traffic. |
| 35 | */ |
| 36 | export interface Network { |
| 37 | /** |
| 38 | * UUID of the `cfd_tunnel` that provides connectivity to the host. |
| 39 | * Accepts a reference to a `Cloudflare.Tunnel.Tunnel` output. |
| 40 | */ |
| 41 | tunnelId: string; |
| 42 | } |
| 43 | |
| 44 | /** |
| 45 | * Network a private hostname is resolved and reached through. |
| 46 | */ |
| 47 | export interface ResolverNetwork { |
| 48 | /** |
| 49 | * UUID of the `cfd_tunnel` that provides connectivity to the host. |
| 50 | * Accepts a reference to a `Cloudflare.Tunnel.Tunnel` output. |
| 51 | */ |
| 52 | tunnelId: string; |
| 53 | /** |
| 54 | * IP addresses of the DNS resolvers used to resolve the hostname |
| 55 | * inside the private network. |
| 56 | */ |
| 57 | resolverIps?: string[]; |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * Private IPv4 host reached through a tunnel. |
| 62 | */ |
| 63 | export interface Ipv4Host { |
| 64 | /** Private IPv4 address of the service. */ |
| 65 | ipv4: string; |
| 66 | /** Tunnel-backed network the address lives in. */ |
| 67 | network: Network; |
| 68 | } |
| 69 | |
| 70 | /** |
| 71 | * Private IPv6 host reached through a tunnel. |
| 72 | */ |
| 73 | export interface Ipv6Host { |
| 74 | /** Private IPv6 address of the service. */ |
| 75 | ipv6: string; |
| 76 | /** Tunnel-backed network the address lives in. */ |
| 77 | network: Network; |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * Dual-stack (IPv4 + IPv6) host reached through a tunnel. |
| 82 | */ |
| 83 | export interface DualStackHost { |
| 84 | /** Private IPv4 address of the service. */ |
| 85 | ipv4: string; |
| 86 | /** Private IPv6 address of the service. */ |
| 87 | ipv6: string; |
| 88 | /** Tunnel-backed network the addresses live in. */ |
| 89 | network: Network; |
| 90 | } |
| 91 | |
| 92 | /** |
| 93 | * Private hostname resolved and reached through a tunnel. |
| 94 | */ |
| 95 | export interface HostnameHost { |
| 96 | /** Private hostname of the service (e.g. `db.internal`). */ |
| 97 | hostname: string; |
| 98 | /** Tunnel-backed resolver network used to resolve and reach the host. */ |
| 99 | resolverNetwork: ResolverNetwork; |
| 100 | } |
| 101 | |
| 102 | /** |
| 103 | * Host a directory service is reachable at — a private IPv4/IPv6 |
| 104 | * address or a hostname, always reached through a Cloudflare Tunnel. |
| 105 | */ |
| 106 | export type Host = Ipv4Host | Ipv6Host | DualStackHost | HostnameHost; |
| 107 | |
| 108 | /** |
| 109 | * Fully-resolved host shape returned in Output Attributes (all tunnel |
| 110 | * references resolved to concrete UUID strings). |
| 111 | */ |
| 112 | export type HostAttributes = |
| 113 | | { ipv4: string; network: { tunnelId: string } } |
| 114 | | { ipv6: string; network: { tunnelId: string } } |
| 115 | | { ipv4: string; ipv6: string; network: { tunnelId: string } } |
| 116 | | { |
| 117 | hostname: string; |
| 118 | resolverNetwork: { tunnelId: string; resolverIps?: string[] }; |
| 119 | }; |
| 120 | } |
| 121 | |
| 122 | export type DirectoryServiceProps = { |
| 123 | /** |
| 124 | * Display name of the directory service. Must be unique within the |
| 125 | * account. If omitted, a unique name is generated from the app, stage, |
| 126 | * and logical ID. |
| 127 | * @default ${app}-${stage}-${id} |
| 128 | */ |
| 129 | name?: string; |
| 130 | /** |
| 131 | * Service protocol: `tcp` (arbitrary TCP, e.g. a database) or `http` |
| 132 | * (plain HTTP/HTTPS origins). |
| 133 | */ |
| 134 | type: DirectoryServiceType; |
| 135 | /** |
| 136 | * Host the service is reachable at — IPv4, IPv6, dual-stack, or |
| 137 | * hostname, reached through a Cloudflare Tunnel. |
| 138 | */ |
| 139 | host: DirectoryService.Host; |
| 140 | /** |
| 141 | * Port the service listens on. Only valid for `type: "tcp"` services. |
| 142 | */ |
| 143 | tcpPort?: number; |
| 144 | /** |
| 145 | * Port for plain HTTP traffic. Only valid for `type: "http"` services. |
| 146 | * @default 80 |
| 147 | */ |
| 148 | httpPort?: number; |
| 149 | /** |
| 150 | * Port for HTTPS traffic. Only valid for `type: "http"` services. |
| 151 | * @default 443 |
| 152 | */ |
| 153 | httpsPort?: number; |
| 154 | /** |
| 155 | * Application protocol hint for `tcp` services (e.g. `postgresql`). |
| 156 | */ |
| 157 | appProtocol?: DirectoryServiceAppProtocol; |
| 158 | /** |
| 159 | * TLS settings used when connecting to the service. |
| 160 | * @default { certVerificationMode: "verify_full" } |
| 161 | */ |
| 162 | tlsSettings?: { |
| 163 | /** Certificate verification mode (e.g. `verify_full`). */ |
| 164 | certVerificationMode: string; |
| 165 | }; |
| 166 | }; |
| 167 | |
| 168 | export type DirectoryServiceAttributes = { |
| 169 | /** |
| 170 | * Unique identifier of the directory service. |
| 171 | */ |
| 172 | serviceId: string; |
| 173 | /** |
| 174 | * The Cloudflare account the service belongs to. |
| 175 | */ |
| 176 | accountId: string; |
| 177 | /** |
| 178 | * Display name of the service. |
| 179 | */ |
| 180 | name: string; |
| 181 | /** |
| 182 | * Service protocol (`tcp` or `http`). |
| 183 | */ |
| 184 | type: DirectoryServiceType; |
| 185 | /** |
| 186 | * Host the service is reachable at, with tunnel references resolved. |
| 187 | */ |
| 188 | host: DirectoryService.HostAttributes; |
| 189 | /** |
| 190 | * TCP port (only set for `tcp` services). |
| 191 | */ |
| 192 | tcpPort: number | undefined; |
| 193 | /** |
| 194 | * HTTP port (only set for `http` services). |
| 195 | */ |
| 196 | httpPort: number | undefined; |
| 197 | /** |
| 198 | * HTTPS port (only set for `http` services). |
| 199 | */ |
| 200 | httpsPort: number | undefined; |
| 201 | /** |
| 202 | * Application protocol hint (only set for `tcp` services). |
| 203 | */ |
| 204 | appProtocol: DirectoryServiceAppProtocol | undefined; |
| 205 | /** |
| 206 | * Certificate verification mode in effect. |
| 207 | */ |
| 208 | certVerificationMode: string | undefined; |
| 209 | /** |
| 210 | * When the service was created. |
| 211 | */ |
| 212 | createdAt: string | undefined; |
| 213 | /** |
| 214 | * When the service was last updated. |
| 215 | */ |
| 216 | updatedAt: string | undefined; |
| 217 | }; |
| 218 | |
| 219 | export type DirectoryService = Resource< |
| 220 | TypeId, |
| 221 | DirectoryServiceProps, |
| 222 | DirectoryServiceAttributes, |
| 223 | never, |
| 224 | Providers |
| 225 | >; |
| 226 | |
| 227 | /** |
| 228 | * A Cloudflare Connectivity Directory service — a named entry in the |
| 229 | * account's private-network service directory that maps a service name to |
| 230 | * a private host (IP or hostname) reachable through a Cloudflare Tunnel. |
| 231 | * |
| 232 | * Directory services are the registry behind Workers VPC and Zero Trust |
| 233 | * private-network connectivity: a `tcp` service describes a database-style |
| 234 | * origin (with an optional `appProtocol` hint), an `http` service describes |
| 235 | * an HTTP/HTTPS origin with explicit ports. |
| 236 | * |
| 237 | * Names are unique within the account. All properties — including the host |
| 238 | * and even the protocol type — are mutable in place via a full PUT; nothing |
| 239 | * forces a replacement except moving accounts. |
| 240 | * ### Creating a Directory Service |
| 241 | * **Example:** TCP database service through a tunnel |
| 242 | * ```typescript |
| 243 | * const tunnel = yield* Cloudflare.Tunnel.Tunnel("DbTunnel", { |
| 244 | * ingress: [{ service: "tcp://localhost:5432" }], |
| 245 | * }); |
| 246 | * const db = yield* Cloudflare.Connectivity.DirectoryService("Postgres", { |
| 247 | * type: "tcp", |
| 248 | * tcpPort: 5432, |
| 249 | * appProtocol: "postgresql", |
| 250 | * host: { ipv4: "10.0.0.21", network: { tunnelId: tunnel.tunnelId } }, |
| 251 | * }); |
| 252 | * ``` |
| 253 | * |
| 254 | * **Example:** HTTP service on a private hostname |
| 255 | * ```typescript |
| 256 | * const api = yield* Cloudflare.Connectivity.DirectoryService("InternalApi", { |
| 257 | * type: "http", |
| 258 | * httpPort: 8080, |
| 259 | * httpsPort: 8443, |
| 260 | * host: { |
| 261 | * hostname: "api.internal", |
| 262 | * resolverNetwork: { tunnelId: tunnel.tunnelId, resolverIps: ["10.0.0.53"] }, |
| 263 | * }, |
| 264 | * }); |
| 265 | * ``` |
| 266 | * |
| 267 | * ### Updating |
| 268 | * **Example:** Changing the host in place |
| 269 | * ```typescript |
| 270 | * // Host, ports, name, and TLS settings are all mutable — the service |
| 271 | * // keeps its serviceId across updates. |
| 272 | * const db = yield* Cloudflare.Connectivity.DirectoryService("Postgres", { |
| 273 | * type: "tcp", |
| 274 | * tcpPort: 5432, |
| 275 | * host: { |
| 276 | * hostname: "db.internal", |
| 277 | * resolverNetwork: { tunnelId: tunnel.tunnelId }, |
| 278 | * }, |
| 279 | * }); |
| 280 | * ``` |
| 281 | * |
| 282 | * @see https://developers.cloudflare.com/cloudflare-one/ |
| 283 | * |
| 284 | * @resource |
| 285 | * @product Connectivity |
| 286 | * @category Cloudflare One (Zero Trust) |
| 287 | */ |
| 288 | export const DirectoryService = Resource<DirectoryService>(TypeId); |
| 289 | |
| 290 | /** |
| 291 | * Returns true if the given value is a DirectoryService resource. |
| 292 | */ |
| 293 | export const isDirectoryService = (value: unknown): value is DirectoryService => |
| 294 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 295 | |
| 296 | export const DirectoryServiceProvider = () => |
| 297 | Provider.succeed(DirectoryService, { |
| 298 | stables: ["serviceId", "accountId", "createdAt"], |
| 299 | diff: Effect.fn(function* ({ news, output }) { |
| 300 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 301 | if (!isResolved(news)) return undefined; |
| 302 | // Directory services are account-scoped; moving accounts replaces. |
| 303 | if ((output?.accountId ?? accountId) !== accountId) { |
| 304 | return { action: "replace" } as const; |
| 305 | } |
| 306 | // Everything else (name, type, host, ports, appProtocol, TLS) is |
| 307 | // mutable via the full-body PUT — let the engine default to update. |
| 308 | return undefined; |
| 309 | }), |
| 310 | read: Effect.fn(function* ({ id, output, olds }) { |
| 311 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 312 | const acct = output?.accountId ?? accountId; |
| 313 | |
| 314 | // Owned path: refresh by the persisted service id. |
| 315 | if (output?.serviceId) { |
| 316 | const observed = yield* getService(acct, output.serviceId); |
| 317 | return observed ? toAttributes(observed, acct) : undefined; |
| 318 | } |
| 319 | |
| 320 | // Cold read — recover from lost state by matching the deterministic |
| 321 | // physical name. Names are unique within the account (the API |
| 322 | // rejects duplicates), but the service carries no ownership markers, |
| 323 | // so brand the match `Unowned` and let the engine gate takeover |
| 324 | // behind the adopt policy. |
| 325 | const name = yield* createServiceName(id, olds?.name); |
| 326 | const match = yield* findByName(acct, name); |
| 327 | if (match) return Unowned(toAttributes(match, acct)); |
| 328 | return undefined; |
| 329 | }), |
| 330 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 331 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 332 | const acct = output?.accountId ?? accountId; |
| 333 | const name = yield* createServiceName(id, news.name); |
| 334 | |
| 335 | // Observe — the serviceId cached on `output` is a hint, not a |
| 336 | // guarantee: a missing service falls through to a name scan |
| 337 | // (adoption / lost-state recovery) and then to create. |
| 338 | let observed = output?.serviceId |
| 339 | ? yield* getService(acct, output.serviceId) |
| 340 | : undefined; |
| 341 | if (!observed) { |
| 342 | observed = yield* findByName(acct, name); |
| 343 | } |
| 344 | |
| 345 | // Ensure — create when missing. A concurrent create surfaces as |
| 346 | // the typed `VpcServiceNameAlreadyExists` (Cloudflare code 5101): |
| 347 | // converge by re-scanning for the service that won the race. |
| 348 | if (!observed) { |
| 349 | const created = yield* connectivity |
| 350 | .createDirectoryService({ |
| 351 | accountId: acct, |
| 352 | name, |
| 353 | ...toRequestBody(news), |
| 354 | }) |
| 355 | .pipe( |
| 356 | Effect.catchTag("VpcServiceNameAlreadyExists", (error) => |
| 357 | findByName(acct, name).pipe( |
| 358 | Effect.flatMap((existing) => |
| 359 | existing ? Effect.succeed(existing) : Effect.fail(error), |
| 360 | ), |
| 361 | ), |
| 362 | ), |
| 363 | ); |
| 364 | observed = created; |
| 365 | } |
| 366 | |
| 367 | // Sync — diff observed cloud state against desired. The update API |
| 368 | // is a full-body PUT, so send everything, but skip the call |
| 369 | // entirely on a no-op. |
| 370 | const current = toAttributes(observed, acct); |
| 371 | if (!isDirty(current, name, news)) { |
| 372 | return current; |
| 373 | } |
| 374 | const updated = yield* connectivity.updateDirectoryService({ |
| 375 | accountId: acct, |
| 376 | serviceId: current.serviceId, |
| 377 | name, |
| 378 | ...toRequestBody(news), |
| 379 | }); |
| 380 | return toAttributes(updated, acct); |
| 381 | }), |
| 382 | delete: Effect.fn(function* ({ output }) { |
| 383 | // Idempotent — an already-gone service surfaces as the typed |
| 384 | // `VpcServiceNotFound` (Cloudflare code 5104). |
| 385 | yield* connectivity |
| 386 | .deleteDirectoryService({ |
| 387 | accountId: output.accountId, |
| 388 | serviceId: output.serviceId, |
| 389 | }) |
| 390 | .pipe(Effect.catchTag("VpcServiceNotFound", () => Effect.void)); |
| 391 | }), |
| 392 | // Account-scoped collection: enumerate every directory service in the |
| 393 | // account, exhaustively paginating the list API, and hydrate each into |
| 394 | // the same Attributes shape `read` returns. |
| 395 | list: Effect.fn(function* () { |
| 396 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 397 | return yield* connectivity.listDirectoryServices |
| 398 | .pages({ accountId }) |
| 399 | .pipe( |
| 400 | Stream.runCollect, |
| 401 | Effect.map((chunk) => |
| 402 | Array.from(chunk).flatMap((page) => |
| 403 | (page.result ?? []).map((service) => |
| 404 | toAttributes(service, accountId), |
| 405 | ), |
| 406 | ), |
| 407 | ), |
| 408 | ); |
| 409 | }), |
| 410 | }); |
| 411 | |
| 412 | type ObservedService = connectivity.GetDirectoryServiceResponse; |
| 413 | |
| 414 | const createServiceName = (id: string, name: string | undefined) => |
| 415 | Effect.gen(function* () { |
| 416 | if (name) return name; |
| 417 | return yield* createPhysicalName({ id, lowercase: true, maxLength: 63 }); |
| 418 | }); |
| 419 | |
| 420 | /** |
| 421 | * Read a directory service by id, mapping "gone" (`VpcServiceNotFound`, |
| 422 | * Cloudflare error code 5104) to `undefined`. |
| 423 | */ |
| 424 | const getService = (accountId: string, serviceId: string) => |
| 425 | connectivity |
| 426 | .getDirectoryService({ accountId, serviceId }) |
| 427 | .pipe( |
| 428 | Effect.catchTag("VpcServiceNotFound", () => Effect.succeed(undefined)), |
| 429 | ); |
| 430 | |
| 431 | /** |
| 432 | * Find a directory service by its (account-unique) name. |
| 433 | */ |
| 434 | const findByName = (accountId: string, name: string) => |
| 435 | connectivity.listDirectoryServices.items({ accountId }).pipe( |
| 436 | Stream.filter((s) => s.name === name), |
| 437 | Stream.runHead, |
| 438 | Effect.map(Option.getOrUndefined), |
| 439 | ); |
| 440 | |
| 441 | /** |
| 442 | * Build the shared create/update request body from desired props. Inputs |
| 443 | * (tunnel references) have been resolved to concrete strings by the Plan |
| 444 | * layer before reconcile runs. |
| 445 | */ |
| 446 | const toRequestBody = (news: DirectoryServiceProps) => { |
| 447 | const host = news.host; |
| 448 | const requestHost = |
| 449 | "hostname" in host |
| 450 | ? { |
| 451 | hostname: host.hostname, |
| 452 | resolverNetwork: { |
| 453 | tunnelId: host.resolverNetwork.tunnelId, |
| 454 | resolverIps: host.resolverNetwork.resolverIps, |
| 455 | }, |
| 456 | } |
| 457 | : "ipv4" in host && "ipv6" in host |
| 458 | ? { |
| 459 | ipv4: host.ipv4, |
| 460 | ipv6: host.ipv6, |
| 461 | network: { tunnelId: host.network.tunnelId }, |
| 462 | } |
| 463 | : "ipv4" in host |
| 464 | ? { ipv4: host.ipv4, network: { tunnelId: host.network.tunnelId } } |
| 465 | : { ipv6: host.ipv6, network: { tunnelId: host.network.tunnelId } }; |
| 466 | return { |
| 467 | type: news.type, |
| 468 | host: requestHost, |
| 469 | tcpPort: news.tcpPort, |
| 470 | httpPort: news.httpPort, |
| 471 | httpsPort: news.httpsPort, |
| 472 | appProtocol: news.appProtocol, |
| 473 | tlsSettings: news.tlsSettings, |
| 474 | }; |
| 475 | }; |
| 476 | |
| 477 | /** |
| 478 | * Compare observed attributes against the desired props (with the resolved |
| 479 | * physical name) to decide whether the full-body PUT can be skipped. |
| 480 | */ |
| 481 | const isDirty = ( |
| 482 | current: DirectoryServiceAttributes, |
| 483 | name: string, |
| 484 | news: DirectoryServiceProps, |
| 485 | ): boolean => { |
| 486 | if (current.name !== name) return true; |
| 487 | if (current.type !== news.type) return true; |
| 488 | if (news.tcpPort !== undefined && current.tcpPort !== news.tcpPort) { |
| 489 | return true; |
| 490 | } |
| 491 | if (news.httpPort !== undefined && current.httpPort !== news.httpPort) { |
| 492 | return true; |
| 493 | } |
| 494 | if (news.httpsPort !== undefined && current.httpsPort !== news.httpsPort) { |
| 495 | return true; |
| 496 | } |
| 497 | if ( |
| 498 | news.appProtocol !== undefined && |
| 499 | current.appProtocol !== news.appProtocol |
| 500 | ) { |
| 501 | return true; |
| 502 | } |
| 503 | if ( |
| 504 | news.tlsSettings !== undefined && |
| 505 | current.certVerificationMode !== news.tlsSettings.certVerificationMode |
| 506 | ) { |
| 507 | return true; |
| 508 | } |
| 509 | return !sameHost(current.host, news.host); |
| 510 | }; |
| 511 | |
| 512 | /** |
| 513 | * Structural host equality between the observed (resolved) host and the |
| 514 | * desired host props. |
| 515 | */ |
| 516 | const sameHost = ( |
| 517 | observed: DirectoryService.HostAttributes, |
| 518 | desired: DirectoryService.Host, |
| 519 | ): boolean => { |
| 520 | const canon = (h: { |
| 521 | hostname?: string; |
| 522 | ipv4?: string; |
| 523 | ipv6?: string; |
| 524 | tunnelId?: unknown; |
| 525 | resolverIps?: string[] | undefined; |
| 526 | }) => |
| 527 | JSON.stringify([ |
| 528 | h.hostname, |
| 529 | h.ipv4, |
| 530 | h.ipv6, |
| 531 | h.tunnelId, |
| 532 | [...(h.resolverIps ?? [])].sort(), |
| 533 | ]); |
| 534 | const flat = (h: DirectoryService.HostAttributes | DirectoryService.Host) => |
| 535 | Predicate.hasProperty(h, "hostname") |
| 536 | ? { |
| 537 | hostname: h.hostname, |
| 538 | tunnelId: h.resolverNetwork.tunnelId, |
| 539 | resolverIps: h.resolverNetwork.resolverIps ?? undefined, |
| 540 | } |
| 541 | : { |
| 542 | ...(Predicate.hasProperty(h, "ipv4") ? { ipv4: h.ipv4 } : {}), |
| 543 | ...(Predicate.hasProperty(h, "ipv6") ? { ipv6: h.ipv6 } : {}), |
| 544 | tunnelId: h.network.tunnelId, |
| 545 | }; |
| 546 | return canon(flat(observed)) === canon(flat(desired)); |
| 547 | }; |
| 548 | |
| 549 | const toHostAttributes = ( |
| 550 | host: ObservedService["host"], |
| 551 | ): DirectoryService.HostAttributes => { |
| 552 | if ("hostname" in host) { |
| 553 | return { |
| 554 | hostname: host.hostname, |
| 555 | resolverNetwork: { |
| 556 | tunnelId: host.resolverNetwork.tunnelId, |
| 557 | ...(host.resolverNetwork.resolverIps |
| 558 | ? { resolverIps: [...host.resolverNetwork.resolverIps] } |
| 559 | : {}), |
| 560 | }, |
| 561 | }; |
| 562 | } |
| 563 | if ("ipv4" in host && "ipv6" in host) { |
| 564 | return { |
| 565 | ipv4: host.ipv4, |
| 566 | ipv6: host.ipv6, |
| 567 | network: { tunnelId: host.network.tunnelId }, |
| 568 | }; |
| 569 | } |
| 570 | if ("ipv4" in host) { |
| 571 | return { ipv4: host.ipv4, network: { tunnelId: host.network.tunnelId } }; |
| 572 | } |
| 573 | return { ipv6: host.ipv6, network: { tunnelId: host.network.tunnelId } }; |
| 574 | }; |
| 575 | |
| 576 | const toAttributes = ( |
| 577 | service: { |
| 578 | serviceId?: string | null; |
| 579 | name: string; |
| 580 | // Distilled widens generated string enums to open unions (`string & {}`); |
| 581 | // the API only ever returns the known variants here. |
| 582 | type: string; |
| 583 | host: ObservedService["host"]; |
| 584 | tcpPort?: number | null; |
| 585 | httpPort?: number | null; |
| 586 | httpsPort?: number | null; |
| 587 | appProtocol?: DirectoryServiceAppProtocol | null; |
| 588 | tlsSettings?: { certVerificationMode: string } | null; |
| 589 | createdAt?: string | null; |
| 590 | updatedAt?: string | null; |
| 591 | }, |
| 592 | accountId: string, |
| 593 | ): DirectoryServiceAttributes => ({ |
| 594 | serviceId: service.serviceId!, |
| 595 | accountId, |
| 596 | name: service.name, |
| 597 | type: service.type as DirectoryServiceType, |
| 598 | host: toHostAttributes(service.host), |
| 599 | tcpPort: service.tcpPort ?? undefined, |
| 600 | httpPort: service.httpPort ?? undefined, |
| 601 | httpsPort: service.httpsPort ?? undefined, |
| 602 | appProtocol: service.appProtocol ?? undefined, |
| 603 | certVerificationMode: service.tlsSettings?.certVerificationMode ?? undefined, |
| 604 | createdAt: service.createdAt ?? undefined, |
| 605 | updatedAt: service.updatedAt ?? undefined, |
| 606 | }); |