| 1 | import * as Effect from "effect/Effect"; |
| 2 | import { Unowned } from "../AdoptPolicy.ts"; |
| 3 | import { isResolved } from "../Diff.ts"; |
| 4 | import * as Provider from "../Provider.ts"; |
| 5 | import { |
| 6 | DEV_TIMESTAMP, |
| 7 | attrOrString, |
| 8 | devId, |
| 9 | devProvider, |
| 10 | } from "./Internal/DevStub.ts"; |
| 11 | import * as ProviderLayer from "../Local/ProviderLayer.ts"; |
| 12 | import { Resource } from "../Resource.ts"; |
| 13 | import { |
| 14 | PrismaClient, |
| 15 | isConflict, |
| 16 | isNotFound, |
| 17 | type PrismaManagementClient, |
| 18 | } from "./Client.ts"; |
| 19 | import type { App } from "./App.ts"; |
| 20 | import type { Compute } from "./Compute.ts"; |
| 21 | import type { Providers } from "./Providers.ts"; |
| 22 | import { |
| 23 | concreteIdOf, |
| 24 | concreteIdsChanged, |
| 25 | isInputObject, |
| 26 | isPrismaDevId, |
| 27 | } from "./Refs.ts"; |
| 28 | import type { CustomDomain as ApiCustomDomain } from "./Types.ts"; |
| 29 | |
| 30 | type AppReference = string | App | Compute; |
| 31 | |
| 32 | export interface CustomDomainProps { |
| 33 | /** |
| 34 | * App ID or Compute output that owns the domain. The App must be attached to |
| 35 | * the project's current default branch. |
| 36 | */ |
| 37 | app: AppReference; |
| 38 | /** |
| 39 | * Hostname to attach to the app. |
| 40 | */ |
| 41 | hostname: string; |
| 42 | } |
| 43 | |
| 44 | export interface CustomDomain extends Resource< |
| 45 | "Prisma.CustomDomain", |
| 46 | CustomDomainProps, |
| 47 | { |
| 48 | /** |
| 49 | * Prisma custom domain ID. |
| 50 | */ |
| 51 | customDomainId: string; |
| 52 | /** |
| 53 | * Hostname attached to the app. |
| 54 | */ |
| 55 | hostname: string; |
| 56 | /** |
| 57 | * Current app ID that owns the domain. |
| 58 | */ |
| 59 | appId: string; |
| 60 | /** |
| 61 | * Prisma normalized custom domain provisioning status. |
| 62 | */ |
| 63 | status: ApiCustomDomain["status"]; |
| 64 | /** |
| 65 | * Raw custom-domain status returned by Foundry. |
| 66 | */ |
| 67 | foundryStatus: string; |
| 68 | /** |
| 69 | * Failure reason returned by Prisma, when provisioning failed. |
| 70 | */ |
| 71 | failureReason: string | null; |
| 72 | /** |
| 73 | * Failure category returned by Prisma, when provisioning failed. |
| 74 | */ |
| 75 | failureCategory: ApiCustomDomain["failureCategory"]; |
| 76 | /** |
| 77 | * Certificate expiration timestamp, when available. |
| 78 | */ |
| 79 | certExpiresAt: string | null; |
| 80 | /** |
| 81 | * DNS records the hostname should point at. |
| 82 | */ |
| 83 | dnsRecords: ApiCustomDomain["dnsRecords"]; |
| 84 | /** |
| 85 | * ISO timestamp when the custom domain was created. |
| 86 | */ |
| 87 | createdAt: string; |
| 88 | /** |
| 89 | * ISO timestamp when the custom domain was last updated. |
| 90 | */ |
| 91 | updatedAt: string; |
| 92 | }, |
| 93 | never, |
| 94 | Providers |
| 95 | > {} |
| 96 | |
| 97 | /** |
| 98 | * A Prisma app custom domain. |
| 99 | * |
| 100 | * Domains can only attach to Apps on the project's current default branch. |
| 101 | * Creating this resource starts asynchronous DNS and certificate provisioning; |
| 102 | * configure the returned `dnsRecords` and inspect `status`, `foundryStatus`, |
| 103 | * and `failureReason` before routing production traffic. |
| 104 | * |
| 105 | * App and hostname changes are intentionally rejected because the Management |
| 106 | * API cannot replace a live domain atomically. Create a second resource, |
| 107 | * verify DNS and TLS, cut traffic over, and then remove the old resource. |
| 108 | * |
| 109 | * ### Creating a Custom Domain |
| 110 | * **Example:** Attach a hostname to an app |
| 111 | * ```typescript |
| 112 | * const domain = yield* Prisma.CustomDomain("api-domain", { |
| 113 | * app: api.appId, |
| 114 | * hostname: "api.example.com", |
| 115 | * }); |
| 116 | * ``` |
| 117 | * |
| 118 | * @resource |
| 119 | * @product Compute |
| 120 | */ |
| 121 | export const CustomDomain = Resource<CustomDomain>("Prisma.CustomDomain"); |
| 122 | |
| 123 | const attrsFrom = (domain: ApiCustomDomain): CustomDomain["Attributes"] => ({ |
| 124 | customDomainId: domain.id, |
| 125 | hostname: domain.hostname, |
| 126 | appId: domain.appId, |
| 127 | status: domain.status, |
| 128 | foundryStatus: domain.foundryStatus, |
| 129 | failureReason: domain.failureReason, |
| 130 | failureCategory: domain.failureCategory, |
| 131 | certExpiresAt: domain.certExpiresAt, |
| 132 | dnsRecords: domain.dnsRecords, |
| 133 | createdAt: domain.createdAt, |
| 134 | updatedAt: domain.updatedAt, |
| 135 | }); |
| 136 | |
| 137 | const normalizeHostname = (hostname: string) => |
| 138 | hostname.trim().replace(/\.$/, "").toLowerCase(); |
| 139 | |
| 140 | const sameHostname = (left: string, right: string) => |
| 141 | normalizeHostname(left) === normalizeHostname(right); |
| 142 | |
| 143 | const adoptionRequiredError = ( |
| 144 | hostname: string, |
| 145 | appId: string, |
| 146 | detail: string, |
| 147 | ) => |
| 148 | new Error( |
| 149 | `Prisma custom domain '${hostname}' ${detail} on App '${appId}' but is not owned by this resource. Import it with explicit adoption instead of silently taking it over.`, |
| 150 | ); |
| 151 | |
| 152 | const appIdValue = (app: AppReference | undefined) => |
| 153 | typeof app === "string" ? app : app?.appId; |
| 154 | |
| 155 | const unresolvedAppIdOf = (app: AppReference | undefined) => |
| 156 | concreteIdOf(appIdValue(app)); |
| 157 | |
| 158 | const resolveAppId = (app: AppReference) => { |
| 159 | const value = appIdValue(app); |
| 160 | return typeof value === "string" |
| 161 | ? Effect.succeed(value) |
| 162 | : Effect.fail(new Error("Unable to resolve Prisma app id.")); |
| 163 | }; |
| 164 | |
| 165 | const findDomain = ( |
| 166 | client: PrismaManagementClient, |
| 167 | appId: string, |
| 168 | hostname: string, |
| 169 | ) => |
| 170 | client.listAppDomains(appId).pipe( |
| 171 | Effect.catchIf(isNotFound, () => Effect.succeed([])), |
| 172 | Effect.flatMap((domains) => { |
| 173 | const matches = domains.filter((domain) => |
| 174 | sameHostname(domain.hostname, hostname), |
| 175 | ); |
| 176 | return matches.length > 1 |
| 177 | ? Effect.fail( |
| 178 | new Error( |
| 179 | `Prisma app '${appId}' has multiple custom domains matching '${hostname}'; refusing to select one arbitrarily.`, |
| 180 | ), |
| 181 | ) |
| 182 | : Effect.succeed(matches[0]); |
| 183 | }), |
| 184 | ); |
| 185 | |
| 186 | const ensureDefaultBranchApp = ( |
| 187 | client: PrismaManagementClient, |
| 188 | appId: string, |
| 189 | ) => |
| 190 | Effect.gen(function* () { |
| 191 | const app = yield* client.getApp(appId); |
| 192 | if (!app.branchId) { |
| 193 | return yield* Effect.fail( |
| 194 | new Error( |
| 195 | "Prisma custom domains can only be attached to apps on the default Branch.", |
| 196 | ), |
| 197 | ); |
| 198 | } |
| 199 | const branch = yield* client |
| 200 | .getBranch(app.branchId) |
| 201 | .pipe( |
| 202 | Effect.catchIf(isNotFound, () => |
| 203 | Effect.fail( |
| 204 | new Error( |
| 205 | `Unable to verify default Branch for Prisma app ${appId}.`, |
| 206 | ), |
| 207 | ), |
| 208 | ), |
| 209 | ); |
| 210 | if (!branch.isDefault) { |
| 211 | return yield* Effect.fail( |
| 212 | new Error( |
| 213 | "Prisma custom domains can only be attached to apps on the default Branch.", |
| 214 | ), |
| 215 | ); |
| 216 | } |
| 217 | }); |
| 218 | |
| 219 | const ProviderLive = () => |
| 220 | Provider.effect( |
| 221 | CustomDomain, |
| 222 | Effect.gen(function* () { |
| 223 | const client = yield* PrismaClient; |
| 224 | return { |
| 225 | stables: ["customDomainId"], |
| 226 | list: Effect.fn(function* () { |
| 227 | const apps = yield* client.listApps(); |
| 228 | const domains = yield* Effect.forEach( |
| 229 | apps, |
| 230 | (app) => |
| 231 | client |
| 232 | .listAppDomains(app.id) |
| 233 | .pipe(Effect.catchIf(isNotFound, () => Effect.succeed([]))), |
| 234 | { concurrency: 8 }, |
| 235 | ); |
| 236 | return domains.flat().map(attrsFrom); |
| 237 | }), |
| 238 | diff: Effect.fn(function* ({ olds, news, output }) { |
| 239 | if (!isInputObject(news)) return undefined; |
| 240 | if (isPrismaDevId(output?.customDomainId)) { |
| 241 | return { action: "update" } as const; |
| 242 | } |
| 243 | const oldAppId = output?.appId ?? unresolvedAppIdOf(olds.app); |
| 244 | const newAppId = isResolved(news.app) |
| 245 | ? unresolvedAppIdOf(news.app as AppReference) |
| 246 | : undefined; |
| 247 | const oldHostname = normalizeHostname( |
| 248 | output?.hostname ?? olds.hostname, |
| 249 | ); |
| 250 | const newHostname = isResolved(news.hostname) |
| 251 | ? normalizeHostname(news.hostname) |
| 252 | : undefined; |
| 253 | if ( |
| 254 | concreteIdsChanged(oldAppId, newAppId) || |
| 255 | (newHostname !== undefined && newHostname !== oldHostname) |
| 256 | ) { |
| 257 | return yield* Effect.fail( |
| 258 | new Error( |
| 259 | `Prisma cannot atomically replace custom domain '${oldHostname}' without risking traffic before the new domain is active. Create a second Prisma.CustomDomain logical resource, verify DNS/TLS and cut traffic over explicitly, then remove this resource.`, |
| 260 | ), |
| 261 | ); |
| 262 | } |
| 263 | // A live failed Foundry attempt reaches the canonical retry endpoint |
| 264 | // below. Each reconcile performs at most one retry and never polls. |
| 265 | if (output?.status === "failed") { |
| 266 | return { action: "update" } as const; |
| 267 | } |
| 268 | return undefined; |
| 269 | }), |
| 270 | read: Effect.fn(function* ({ output, olds }) { |
| 271 | const customDomainId = isPrismaDevId(output?.customDomainId) |
| 272 | ? undefined |
| 273 | : output?.customDomainId; |
| 274 | const domain = customDomainId |
| 275 | ? yield* client |
| 276 | .getCustomDomain(customDomainId) |
| 277 | .pipe( |
| 278 | Effect.catchIf(isNotFound, () => Effect.succeed(undefined)), |
| 279 | ) |
| 280 | : yield* Effect.gen(function* () { |
| 281 | const appId = unresolvedAppIdOf(olds.app); |
| 282 | return appId |
| 283 | ? yield* findDomain(client, appId, olds.hostname) |
| 284 | : undefined; |
| 285 | }); |
| 286 | if (!domain) return undefined; |
| 287 | const attrs = attrsFrom(domain); |
| 288 | return customDomainId === undefined ? Unowned(attrs) : attrs; |
| 289 | }), |
| 290 | reconcile: Effect.fn(function* ({ news, output }) { |
| 291 | const appId = yield* resolveAppId(news.app); |
| 292 | const hostname = normalizeHostname(news.hostname); |
| 293 | const customDomainId = isPrismaDevId(output?.customDomainId) |
| 294 | ? undefined |
| 295 | : output?.customDomainId; |
| 296 | const domain = customDomainId |
| 297 | ? yield* client |
| 298 | .getCustomDomain(customDomainId) |
| 299 | .pipe( |
| 300 | Effect.catchIf(isNotFound, () => Effect.succeed(undefined)), |
| 301 | ) |
| 302 | : yield* findDomain(client, appId, hostname); |
| 303 | const identityMatches = (domain: ApiCustomDomain) => |
| 304 | domain.appId === appId && sameHostname(domain.hostname, hostname); |
| 305 | if (domain && !identityMatches(domain)) { |
| 306 | return yield* Effect.fail( |
| 307 | new Error( |
| 308 | `Prisma custom domain '${customDomainId}' resolves to app '${domain.appId}' and hostname '${domain.hostname}', not requested app '${appId}' and hostname '${hostname}'. Refusing to claim convergence; replace the mismatched domain.`, |
| 309 | ), |
| 310 | ); |
| 311 | } |
| 312 | if (domain && customDomainId === undefined) { |
| 313 | return yield* Effect.fail( |
| 314 | adoptionRequiredError(hostname, appId, "already exists"), |
| 315 | ); |
| 316 | } |
| 317 | if (!domain) { |
| 318 | yield* ensureDefaultBranchApp(client, appId); |
| 319 | } |
| 320 | const reconciled = domain |
| 321 | ? domain.status === "failed" |
| 322 | ? yield* client.retryCustomDomain(domain.id) |
| 323 | : domain |
| 324 | : yield* client.createAppDomain(appId, { hostname }).pipe( |
| 325 | Effect.catchIf(isConflict, () => |
| 326 | Effect.fail( |
| 327 | adoptionRequiredError( |
| 328 | hostname, |
| 329 | appId, |
| 330 | "appeared after the adoption check", |
| 331 | ), |
| 332 | ), |
| 333 | ), |
| 334 | Effect.flatMap((result) => |
| 335 | result.status === 201 |
| 336 | ? Effect.succeed(result.domain) |
| 337 | : Effect.fail( |
| 338 | adoptionRequiredError( |
| 339 | hostname, |
| 340 | appId, |
| 341 | "was returned as already registered by the create request", |
| 342 | ), |
| 343 | ), |
| 344 | ), |
| 345 | ); |
| 346 | if (!identityMatches(reconciled)) { |
| 347 | return yield* Effect.fail( |
| 348 | new Error( |
| 349 | `Prisma custom domain '${reconciled.id}' retry/create response resolves to app '${reconciled.appId}' and hostname '${reconciled.hostname}', not requested app '${appId}' and hostname '${hostname}'. Refusing to persist mismatched identity.`, |
| 350 | ), |
| 351 | ); |
| 352 | } |
| 353 | return attrsFrom(reconciled); |
| 354 | }), |
| 355 | delete: Effect.fn(function* ({ output }) { |
| 356 | if (isPrismaDevId(output.customDomainId)) return; |
| 357 | const domain = yield* client |
| 358 | .getCustomDomain(output.customDomainId) |
| 359 | .pipe(Effect.catchIf(isNotFound, () => Effect.succeed(undefined))); |
| 360 | if (!domain) return; |
| 361 | if ( |
| 362 | domain.appId !== output.appId || |
| 363 | !sameHostname(domain.hostname, output.hostname) |
| 364 | ) { |
| 365 | return yield* Effect.fail( |
| 366 | new Error( |
| 367 | `Prisma custom domain '${output.customDomainId}' no longer matches app '${output.appId}' and hostname '${output.hostname}'. Refusing to delete a mismatched domain.`, |
| 368 | ), |
| 369 | ); |
| 370 | } |
| 371 | yield* client |
| 372 | .deleteCustomDomain(output.customDomainId) |
| 373 | .pipe(Effect.catchIf(isNotFound, () => Effect.void)); |
| 374 | }), |
| 375 | }; |
| 376 | }), |
| 377 | ); |
| 378 | |
| 379 | const ProviderLocal = () => |
| 380 | devProvider(CustomDomain, ["customDomainId"], ({ id, news }) => ({ |
| 381 | customDomainId: devId("custom-domain", id), |
| 382 | hostname: news.hostname, |
| 383 | appId: attrOrString(news.app, "appId"), |
| 384 | status: "active", |
| 385 | foundryStatus: "active", |
| 386 | failureReason: null, |
| 387 | failureCategory: null, |
| 388 | certExpiresAt: null, |
| 389 | dnsRecords: [ |
| 390 | { |
| 391 | type: "CNAME" as const, |
| 392 | name: news.hostname, |
| 393 | value: "localhost", |
| 394 | ttl: null, |
| 395 | }, |
| 396 | ], |
| 397 | createdAt: DEV_TIMESTAMP, |
| 398 | updatedAt: DEV_TIMESTAMP, |
| 399 | })); |
| 400 | |
| 401 | export const CustomDomainProvider = () => |
| 402 | ProviderLayer.dual(CustomDomain, { |
| 403 | local: () => ProviderLocal(), |
| 404 | live: () => ProviderLive(), |
| 405 | }); |