| 1 | import * as Neon from "@distilled.cloud/neon"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Equal from "effect/Equal"; |
| 5 | import type * as Redacted from "effect/Redacted"; |
| 6 | import type { Input } from "../Input.ts"; |
| 7 | import { isPlainData } from "../Util/data.ts"; |
| 8 | import { OwnedBySomeoneElse, Unowned } from "../AdoptPolicy.ts"; |
| 9 | import { arrayEquals } from "../Util/equal.ts"; |
| 10 | import { isResolved } from "../Diff.ts"; |
| 11 | import * as Provider from "../Provider.ts"; |
| 12 | import { Resource } from "../Resource.ts"; |
| 13 | import { |
| 14 | InvalidBranchScope, |
| 15 | resolveBranchScope, |
| 16 | type BranchScope, |
| 17 | } from "./BranchScope.ts"; |
| 18 | import type { Providers } from "./Providers.ts"; |
| 19 | |
| 20 | /** Unspecified settings are preserved. Removing previously managed settings requires an explicit reset value. */ |
| 21 | export 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 | |
| 61 | export 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 | |
| 76 | export 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 | */ |
| 107 | export const Auth = Resource<Auth>("Neon.Auth"); |
| 108 | |
| 109 | export class InvalidManagedAuth extends Data.TaggedError("InvalidManagedAuth")<{ |
| 110 | message: string; |
| 111 | }> {} |
| 112 | |
| 113 | /** @internal */ |
| 114 | export 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 */ |
| 123 | export 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 */ |
| 158 | export 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 | |
| 184 | const managedAuthFields = [ |
| 185 | "name", |
| 186 | "allowLocalhost", |
| 187 | "emailAndPassword", |
| 188 | "emailProvider", |
| 189 | "magicLink", |
| 190 | "organization", |
| 191 | "phoneNumber", |
| 192 | "webhook", |
| 193 | ] as const; |
| 194 | |
| 195 | const 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 */ |
| 210 | export 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 | |
| 219 | const observeAuth = (scope: { projectId: string; branchId: string }) => |
| 220 | Neon.getNeonAuth(authRequest(scope)).pipe( |
| 221 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 222 | ); |
| 223 | |
| 224 | const 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 | |
| 243 | export 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 | }); |