1import * as Effect from "effect/Effect";
2import { Unowned } from "../AdoptPolicy.ts";
3import { isResolved } from "../Diff.ts";
4import * as Provider from "../Provider.ts";
5import {
6 DEV_TIMESTAMP,
7 attrOrString,
8 devId,
9 devProvider,
10} from "./Internal/DevStub.ts";
11import * as ProviderLayer from "../Local/ProviderLayer.ts";
12import { Resource } from "../Resource.ts";
13import {
14 PrismaClient,
15 isConflict,
16 isNotFound,
17 type PrismaManagementClient,
18} from "./Client.ts";
19import type { App } from "./App.ts";
20import type { Compute } from "./Compute.ts";
21import type { Providers } from "./Providers.ts";
22import {
23 concreteIdOf,
24 concreteIdsChanged,
25 isInputObject,
26 isPrismaDevId,
27} from "./Refs.ts";
28import type { CustomDomain as ApiCustomDomain } from "./Types.ts";
29
30type AppReference = string | App | Compute;
31
32export 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
44export 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 */
121export const CustomDomain = Resource<CustomDomain>("Prisma.CustomDomain");
122
123const 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
137const normalizeHostname = (hostname: string) =>
138 hostname.trim().replace(/\.$/, "").toLowerCase();
139
140const sameHostname = (left: string, right: string) =>
141 normalizeHostname(left) === normalizeHostname(right);
142
143const 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
152const appIdValue = (app: AppReference | undefined) =>
153 typeof app === "string" ? app : app?.appId;
154
155const unresolvedAppIdOf = (app: AppReference | undefined) =>
156 concreteIdOf(appIdValue(app));
157
158const 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
165const 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
186const 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
219const 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
379const 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
401export const CustomDomainProvider = () =>
402 ProviderLayer.dual(CustomDomain, {
403 local: () => ProviderLocal(),
404 live: () => ProviderLive(),
405 });