1import * as Neon from "@distilled.cloud/neon";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Equal from "effect/Equal";
5import type * as Redacted from "effect/Redacted";
6import type { Input } from "../Input.ts";
7import { isPlainData } from "../Util/data.ts";
8import { OwnedBySomeoneElse, Unowned } from "../AdoptPolicy.ts";
9import { arrayEquals } from "../Util/equal.ts";
10import { isResolved } from "../Diff.ts";
11import * as Provider from "../Provider.ts";
12import { Resource } from "../Resource.ts";
13import {
14 InvalidBranchScope,
15 resolveBranchScope,
16 type BranchScope,
17} from "./BranchScope.ts";
18import type { Providers } from "./Providers.ts";
19
20/** Unspecified settings are preserved. Removing previously managed settings requires an explicit reset value. */
21export type AuthProps = BranchScope & {
22 /** Database containing neon_auth. Omission preserves the existing database; changing its identity replaces the integration. */
23 database?: string;
24 /** Application name shown in authentication emails. */
25 name?: string;
26 /** Permit localhost origins; explicitly set false in production. */
27 allowLocalhost?: boolean;
28 /** Supported managed email/password settings, using Neon's wire field names. */
29 emailAndPassword?: Omit<
30 Neon.UpdateNeonAuthEmailAndPasswordConfigRequest,
31 "project_id" | "branch_id"
32 >;
33 /** Managed SMTP or shared email settings. Passwords must be redacted. */
34 emailProvider?:
35 | Neon.SharedEmailServer
36 | (Omit<Neon.StandardEmailServer, "password"> & {
37 password?: Redacted.Redacted<string>;
38 });
39 /** Managed magic-link settings. */
40 magicLink?: Omit<
41 Neon.UpdateNeonAuthMagicLinkPluginRequest,
42 "project_id" | "branch_id"
43 >;
44 /** Managed organization plugin settings; does not configure Neon organizations. */
45 organization?: Omit<
46 Neon.UpdateNeonAuthOrganizationPluginRequest,
47 "project_id" | "branch_id"
48 >;
49 /** Managed phone-number plugin settings. */
50 phoneNumber?: Omit<
51 Neon.UpdateNeonAuthPhoneNumberPluginRequest,
52 "project_id" | "branch_id"
53 >;
54 /** Managed webhook settings. */
55 webhook?: Omit<
56 Neon.UpdateNeonAuthWebhookConfigRequest,
57 "project_id" | "branch_id"
58 >;
59};
60
61export interface AuthAttributes {
62 /** Neon project identity. */
63 projectId: string;
64 /** Branch identity; destruction never follows a parent branch. */
65 branchId: string;
66 /** Database holding authentication state. */
67 database: string;
68 /** Public managed Better Auth service URL. */
69 baseUrl: string;
70 /** Public JWKS endpoint for signature verification. */
71 jwksUrl: string;
72 /** Application name observed from Neon. */
73 name: string | undefined;
74}
75
76export interface Auth extends Resource<
77 "Neon.Auth",
78 AuthProps,
79 AuthAttributes,
80 never,
81 Providers
82> {}
83
84/**
85 * Own the managed Better Auth integration on one branch. This is distinct from
86 * Alchemy's deployment authentication provider. Existing or inherited singleton
87 * integrations require explicit adoption. Users and sessions are runtime data.
88 * OAuth providers and trusted origins are owned only by their child resources.
89 * Disabling Auth preserves the neon_auth database schema. Neon refuses to enable
90 * it again while that schema exists; this provider never drops user identity data
91 * to recreate an externally disabled integration.
92 *
93 * ### Enable managed authentication
94 * **Example:** Email and password authentication
95 * ```typescript
96 * const auth = yield* Neon.Auth("Auth", {
97 * branch,
98 * allowLocalhost: false,
99 * emailAndPassword: { enabled: true, require_email_verification: true },
100 * });
101 * yield* Neon.AuthTrustedDomain("SiteOrigin", { auth, domain: site.url });
102 * ```
103 *
104 * @resource
105 * @product Auth
106 */
107export const Auth = Resource<Auth>("Neon.Auth");
108
109export class InvalidManagedAuth extends Data.TaggedError("InvalidManagedAuth")<{
110 message: string;
111}> {}
112
113/** @internal */
114export const authRequest = (scope: {
115 projectId: string;
116 branchId: string;
117}) => ({
118 project_id: scope.projectId,
119 branch_id: scope.branchId,
120});
121
122/** Resolve identity independently of unrelated unresolved settings. @internal */
123export const authPlanScope = Effect.fn(function* (news: Input<BranchScope>) {
124 if (
125 "branch" in news &&
126 news.branch !== undefined &&
127 "project" in news &&
128 news.project !== undefined
129 ) {
130 return yield* new InvalidBranchScope({
131 message: "Specify exactly one of branch or project",
132 });
133 }
134 if ("branch" in news && news.branch !== undefined) {
135 const branch = news.branch;
136 if (
137 "projectId" in branch &&
138 "branchId" in branch &&
139 isResolved(branch.projectId) &&
140 isResolved(branch.branchId)
141 ) {
142 return yield* resolveBranchScope({
143 branch: { projectId: branch.projectId, branchId: branch.branchId },
144 });
145 }
146 } else if ("project" in news && news.project !== undefined) {
147 const project = news.project;
148 if ("projectId" in project && isResolved(project.projectId)) {
149 return yield* resolveBranchScope({
150 project: { projectId: project.projectId },
151 });
152 }
153 }
154 return undefined;
155});
156
157/** Find omitted managed fields without inspecting secret values. @internal */
158export const removedAuthSettings = (
159 olds: object | undefined,
160 news: object,
161 fields: readonly string[],
162): string[] => {
163 if (!olds || !isPlainData(news)) return [];
164 return fields.flatMap((key) => {
165 const oldValue = Reflect.get(olds, key);
166 const newValue = Reflect.get(news, key);
167 if (oldValue === undefined) return [];
168 if (newValue === undefined) return [key];
169 if (
170 isPlainData(oldValue) &&
171 isPlainData(newValue) &&
172 !Array.isArray(oldValue) &&
173 !Array.isArray(newValue) &&
174 Reflect.get(oldValue, "type") === Reflect.get(newValue, "type")
175 ) {
176 return removedAuthSettings(oldValue, newValue, Object.keys(oldValue)).map(
177 (field) => `${key}.${field}`,
178 );
179 }
180 return [];
181 });
182};
183
184const managedAuthFields = [
185 "name",
186 "allowLocalhost",
187 "emailAndPassword",
188 "emailProvider",
189 "magicLink",
190 "organization",
191 "phoneNumber",
192 "webhook",
193] as const;
194
195const validateAuthRemoval = (
196 olds: AuthProps | undefined,
197 news: Input<AuthProps>,
198) => {
199 const removed = removedAuthSettings(olds, news, managedAuthFields);
200 return removed.length === 0
201 ? Effect.void
202 : Effect.fail(
203 new InvalidManagedAuth({
204 message: `Cannot remove managed Auth settings: ${removed.join(", ")}; set explicit reset values instead`,
205 }),
206 );
207};
208
209/** Compare only explicitly managed fields, never serialize secrets. @internal */
210export const authSettingsMatch = (observed: object, desired: object) =>
211 Object.entries(desired).every(
212 ([key, value]) =>
213 value === undefined ||
214 (Array.isArray(value)
215 ? arrayEquals(Reflect.get(observed, key), value)
216 : Equal.equals(Reflect.get(observed, key), value)),
217 );
218
219const observeAuth = (scope: { projectId: string; branchId: string }) =>
220 Neon.getNeonAuth(authRequest(scope)).pipe(
221 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
222 );
223
224const attributes = Effect.fn(function* (
225 scope: { projectId: string; branchId: string },
226 observed: Neon.NeonAuthIntegration,
227) {
228 if (observed.auth_provider !== "better_auth" || !observed.base_url) {
229 return yield* new InvalidManagedAuth({
230 message:
231 "The branch must expose a managed Better Auth integration and base URL",
232 });
233 }
234 return {
235 ...scope,
236 database: observed.db_name,
237 baseUrl: observed.base_url,
238 jwksUrl: observed.jwks_url,
239 name: observed.name,
240 } satisfies AuthAttributes;
241});
242
243export const AuthProvider = () =>
244 Provider.succeed(Auth, {
245 stables: ["projectId", "branchId", "database"],
246 diff: Effect.fn(function* ({ olds, news, output }) {
247 yield* validateAuthRemoval(olds, news);
248 const scope = yield* authPlanScope(news);
249 if (
250 output &&
251 (!scope ||
252 scope.projectId !== output.projectId ||
253 scope.branchId !== output.branchId)
254 ) {
255 return { action: "replace", deleteFirst: true } as const;
256 }
257 if (!isResolved(news)) return;
258 const resolved = scope ?? (yield* resolveBranchScope(news));
259 const previous = output ?? (yield* resolveBranchScope(olds));
260 if (
261 resolved.projectId !== previous.projectId ||
262 resolved.branchId !== previous.branchId ||
263 (news.database ?? output?.database ?? olds.database) !==
264 (output?.database ?? olds.database)
265 ) {
266 return { action: "replace", deleteFirst: true } as const;
267 }
268 if (!output) return;
269 const request = authRequest(resolved);
270 const observed = yield* observeAuth(resolved);
271 if (!observed) return { action: "update" } as const;
272 if (news.name !== undefined && news.name !== observed.name)
273 return { action: "update" } as const;
274 if (
275 news.allowLocalhost !== undefined &&
276 (yield* Neon.getNeonAuthAllowLocalhost(request)).allow_localhost !==
277 news.allowLocalhost
278 )
279 return { action: "update" } as const;
280 if (
281 news.emailAndPassword !== undefined &&
282 !authSettingsMatch(
283 yield* Neon.getNeonAuthEmailAndPasswordConfig(request),
284 news.emailAndPassword,
285 )
286 )
287 return { action: "update" } as const;
288 if (
289 news.emailProvider !== undefined &&
290 !authSettingsMatch(
291 yield* Neon.getNeonAuthEmailProvider(request),
292 news.emailProvider,
293 )
294 )
295 return { action: "update" } as const;
296 if (
297 news.magicLink !== undefined ||
298 news.organization !== undefined ||
299 news.phoneNumber !== undefined
300 ) {
301 const current = yield* Neon.getNeonAuthPluginConfigs(request);
302 if (
303 (news.magicLink !== undefined &&
304 !authSettingsMatch(current.magic_link ?? {}, news.magicLink)) ||
305 (news.organization !== undefined &&
306 !authSettingsMatch(
307 current.organization ?? {},
308 news.organization,
309 )) ||
310 (news.phoneNumber !== undefined &&
311 !authSettingsMatch(current.phone_number ?? {}, news.phoneNumber))
312 )
313 return { action: "update" } as const;
314 }
315 if (
316 news.webhook !== undefined &&
317 !authSettingsMatch(
318 yield* Neon.getNeonAuthWebhookConfig(request),
319 news.webhook,
320 )
321 )
322 return { action: "update" } as const;
323 }),
324 read: Effect.fn(function* ({ olds, output }) {
325 if (
326 !output &&
327 !(olds?.branch?.projectId && olds.branch.branchId) &&
328 !olds?.project?.projectId
329 )
330 return undefined;
331 const scope = output ?? (yield* resolveBranchScope(olds));
332 const observed = yield* observeAuth(scope);
333 if (!observed) return undefined;
334 const attrs = yield* attributes(scope, observed);
335 return output ? attrs : Unowned(attrs);
336 }),
337 reconcile: Effect.fn(function* ({ news, output, olds }) {
338 yield* validateAuthRemoval(olds, news);
339 const scope = yield* resolveBranchScope(news);
340 const database = news.database ?? output?.database;
341 if (
342 output &&
343 (scope.projectId !== output.projectId ||
344 scope.branchId !== output.branchId ||
345 database !== output.database)
346 ) {
347 return yield* new InvalidManagedAuth({
348 message:
349 "Auth identity changed without replacement; refusing to mutate either integration",
350 });
351 }
352 const request = authRequest(scope);
353 let observed = yield* observeAuth(scope);
354 if (observed && !output) {
355 return yield* new OwnedBySomeoneElse({
356 message: "Existing Auth requires explicit adoption",
357 resourceType: "Neon.Auth",
358 });
359 }
360 if (!observed) {
361 yield* Neon.createNeonAuth({
362 ...request,
363 auth_provider: "better_auth",
364 database_name: database,
365 }).pipe(
366 Effect.catchTag(
367 "Conflict",
368 Effect.fn(function* (error) {
369 const raced = yield* observeAuth(scope);
370 if (!raced) return yield* error;
371 if (!output) {
372 return yield* new OwnedBySomeoneElse({
373 message:
374 "Auth integration appeared during creation; explicit adoption is required",
375 resourceType: "Neon.Auth",
376 });
377 }
378 }),
379 ),
380 );
381 observed = yield* Neon.getNeonAuth(request);
382 }
383 yield* attributes(scope, observed);
384 if (news.database !== undefined && observed.db_name !== news.database) {
385 return yield* new InvalidManagedAuth({
386 message:
387 "Existing Auth uses a different database; replace the integration explicitly",
388 });
389 }
390 if (news.name !== undefined && news.name !== observed.name) {
391 yield* Neon.updateNeonAuthConfig({ ...request, name: news.name });
392 }
393 if (news.allowLocalhost !== undefined) {
394 const current = yield* Neon.getNeonAuthAllowLocalhost(request);
395 if (current.allow_localhost !== news.allowLocalhost) {
396 yield* Neon.updateNeonAuthAllowLocalhost({
397 ...request,
398 allow_localhost: news.allowLocalhost,
399 });
400 }
401 }
402 if (news.emailAndPassword !== undefined) {
403 const current = yield* Neon.getNeonAuthEmailAndPasswordConfig(request);
404 if (!authSettingsMatch(current, news.emailAndPassword)) {
405 yield* Neon.updateNeonAuthEmailAndPasswordConfig({
406 ...request,
407 ...news.emailAndPassword,
408 });
409 }
410 }
411 if (news.emailProvider !== undefined) {
412 const current = yield* Neon.getNeonAuthEmailProvider(request);
413 // SMTP passwords may be masked on read, so reassert an explicitly managed secret.
414 if (
415 (news.emailProvider.type === "standard" &&
416 news.emailProvider.password !== undefined) ||
417 !authSettingsMatch(current, news.emailProvider)
418 ) {
419 yield* Neon.updateNeonAuthEmailProvider({
420 ...request,
421 body: news.emailProvider,
422 });
423 }
424 }
425 if (
426 news.magicLink !== undefined ||
427 news.organization !== undefined ||
428 news.phoneNumber !== undefined
429 ) {
430 const current = yield* Neon.getNeonAuthPluginConfigs(request);
431 if (
432 news.magicLink !== undefined &&
433 !authSettingsMatch(current.magic_link ?? {}, news.magicLink)
434 ) {
435 yield* Neon.updateNeonAuthMagicLinkPlugin({
436 ...request,
437 ...news.magicLink,
438 });
439 }
440 if (
441 news.organization !== undefined &&
442 !authSettingsMatch(current.organization ?? {}, news.organization)
443 ) {
444 yield* Neon.updateNeonAuthOrganizationPlugin({
445 ...request,
446 ...news.organization,
447 });
448 }
449 if (
450 news.phoneNumber !== undefined &&
451 !authSettingsMatch(current.phone_number ?? {}, news.phoneNumber)
452 ) {
453 yield* Neon.updateNeonAuthPhoneNumberPlugin({
454 ...request,
455 ...news.phoneNumber,
456 });
457 }
458 }
459 if (news.webhook !== undefined) {
460 const current = yield* Neon.getNeonAuthWebhookConfig(request);
461 if (!authSettingsMatch(current, news.webhook)) {
462 yield* Neon.updateNeonAuthWebhookConfig({
463 ...request,
464 ...news.webhook,
465 });
466 }
467 }
468 return yield* attributes(scope, yield* Neon.getNeonAuth(request));
469 }),
470 delete: Effect.fn(function* ({ output }) {
471 if (yield* observeAuth(output)) {
472 yield* Neon.disableNeonAuth(authRequest(output)).pipe(
473 Effect.catchTag("NotFound", () => Effect.void),
474 );
475 }
476 }),
477 });