| 1 | import * as Neon from "@distilled.cloud/neon"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import { OwnedBySomeoneElse, Unowned } from "../AdoptPolicy.ts"; |
| 5 | import { isResolved } from "../Diff.ts"; |
| 6 | import * as Provider from "../Provider.ts"; |
| 7 | import { Resource } from "../Resource.ts"; |
| 8 | import type { Input } from "../Input.ts"; |
| 9 | import { |
| 10 | authPlanScope, |
| 11 | authRequest, |
| 12 | authSettingsMatch, |
| 13 | removedAuthSettings, |
| 14 | } from "./Auth.ts"; |
| 15 | import { resolveBranchScope, type ResolvedBranchScope } from "./BranchScope.ts"; |
| 16 | import type { Providers } from "./Providers.ts"; |
| 17 | |
| 18 | export type DataApiProps = ( |
| 19 | | { |
| 20 | /** Branch resource or explicit identity, optionally carrying its selected database. */ |
| 21 | branch: { projectId: string; branchId: string; databaseName?: string }; |
| 22 | project?: never; |
| 23 | } |
| 24 | | { |
| 25 | /** Project resource or explicit identity; selects the observed default branch. */ |
| 26 | project: { projectId: string; databaseName?: string }; |
| 27 | branch?: never; |
| 28 | } |
| 29 | ) & { |
| 30 | /** Database served by PostgREST. Infers the referenced resource's selected database, or the branch's only database; required when ambiguous. */ |
| 31 | database?: string; |
| 32 | /** Authentication mechanism. Changes replace the Data API. */ |
| 33 | authProvider?: "neon_auth" | "external"; |
| 34 | /** HTTPS JWKS endpoint required for external authentication. */ |
| 35 | jwksUrl?: string; |
| 36 | /** Display name for an external authentication provider. */ |
| 37 | providerName?: string; |
| 38 | /** Expected JWT audience. Neon still accepts tokens without an audience. */ |
| 39 | jwtAudience?: string; |
| 40 | /** Opt in to public-schema table grants. Defaults to false. */ |
| 41 | addDefaultGrants?: boolean; |
| 42 | /** Skip creating the auth schema and RLS helpers. */ |
| 43 | skipAuthSchema?: boolean; |
| 44 | /** Explicitly owned PostgREST settings. Removing previously managed fields requires an explicit reset value. */ |
| 45 | settings?: Neon.DataAPISettings; |
| 46 | }; |
| 47 | |
| 48 | export interface DataApiAttributes { |
| 49 | /** Neon project identity. */ |
| 50 | projectId: string; |
| 51 | /** Branch identity. */ |
| 52 | branchId: string; |
| 53 | /** Database served by this endpoint. */ |
| 54 | database: string; |
| 55 | /** Public PostgREST base URL. Requests must carry end-user authorization. */ |
| 56 | url: string; |
| 57 | /** Observed deployment status. */ |
| 58 | status: string; |
| 59 | /** Observed PostgREST settings, if exposed by the backend. */ |
| 60 | settings: Neon.DataAPISettings | undefined; |
| 61 | } |
| 62 | |
| 63 | export interface DataApi extends Resource< |
| 64 | "Neon.DataApi", |
| 65 | DataApiProps, |
| 66 | DataApiAttributes, |
| 67 | never, |
| 68 | Providers |
| 69 | > {} |
| 70 | |
| 71 | /** |
| 72 | * Own a branch/database Data API singleton. This resource never grants an |
| 73 | * application the account deployment key. Configure RLS and forward the user's |
| 74 | * token through QueryDataApi. Authentication inputs are creation-only because |
| 75 | * the API only supports updating PostgREST settings. |
| 76 | * |
| 77 | * ### Expose a database through managed authentication |
| 78 | * **Example:** Managed Auth and PostgREST |
| 79 | * ```typescript |
| 80 | * const dataApi = yield* Neon.DataApi("Data", { |
| 81 | * branch, authProvider: "neon_auth", settings: { db_max_rows: 100 }, |
| 82 | * }); |
| 83 | * ``` |
| 84 | * |
| 85 | * @resource |
| 86 | * @product Data API |
| 87 | */ |
| 88 | export const DataApi = Resource<DataApi>("Neon.DataApi"); |
| 89 | |
| 90 | export class InvalidDataApiConfiguration extends Data.TaggedError( |
| 91 | "InvalidDataApiConfiguration", |
| 92 | )<{ |
| 93 | message: string; |
| 94 | }> {} |
| 95 | |
| 96 | const observe = (scope: { |
| 97 | projectId: string; |
| 98 | branchId: string; |
| 99 | database: string; |
| 100 | }) => |
| 101 | Neon.getProjectBranchDataAPI({ |
| 102 | ...authRequest(scope), |
| 103 | database_name: scope.database, |
| 104 | }).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 105 | |
| 106 | const attributes = ( |
| 107 | scope: { projectId: string; branchId: string; database: string }, |
| 108 | observed: Neon.DataAPIReponse, |
| 109 | ): DataApiAttributes => ({ |
| 110 | projectId: scope.projectId, |
| 111 | branchId: scope.branchId, |
| 112 | database: scope.database, |
| 113 | url: observed.url, |
| 114 | status: observed.status, |
| 115 | settings: observed.settings ?? undefined, |
| 116 | }); |
| 117 | |
| 118 | const immutable = [ |
| 119 | "authProvider", |
| 120 | "jwksUrl", |
| 121 | "providerName", |
| 122 | "jwtAudience", |
| 123 | "addDefaultGrants", |
| 124 | "skipAuthSchema", |
| 125 | ] as const; |
| 126 | |
| 127 | const validateDataApi = (news: DataApiProps) => |
| 128 | news.authProvider === "external" && !news.jwksUrl |
| 129 | ? Effect.fail( |
| 130 | new InvalidDataApiConfiguration({ |
| 131 | message: "External authentication requires jwksUrl", |
| 132 | }), |
| 133 | ) |
| 134 | : Effect.void; |
| 135 | |
| 136 | const validateDataApiRemoval = ( |
| 137 | olds: DataApiProps | undefined, |
| 138 | news: Input<DataApiProps>, |
| 139 | ) => { |
| 140 | const removed = removedAuthSettings(olds, news, [...immutable, "settings"]); |
| 141 | return removed.length === 0 |
| 142 | ? Effect.void |
| 143 | : Effect.fail( |
| 144 | new InvalidDataApiConfiguration({ |
| 145 | message: `Cannot remove managed Data API settings: ${removed.join(", ")}; set explicit reset values instead`, |
| 146 | }), |
| 147 | ); |
| 148 | }; |
| 149 | |
| 150 | const resolveDatabase = Effect.fn(function* ( |
| 151 | news: DataApiProps, |
| 152 | scope: ResolvedBranchScope, |
| 153 | ) { |
| 154 | const database = news.database ?? (news.branch ?? news.project)?.databaseName; |
| 155 | if (database !== undefined) { |
| 156 | if (!database) |
| 157 | return yield* new InvalidDataApiConfiguration({ |
| 158 | message: "database must not be empty", |
| 159 | }); |
| 160 | return database; |
| 161 | } |
| 162 | const { databases } = yield* Neon.listProjectBranchDatabases( |
| 163 | authRequest(scope), |
| 164 | ); |
| 165 | if (databases.length !== 1) { |
| 166 | return yield* new InvalidDataApiConfiguration({ |
| 167 | message: |
| 168 | "Set database explicitly when the branch has no unambiguous selected database", |
| 169 | }); |
| 170 | } |
| 171 | return databases[0]!.name; |
| 172 | }); |
| 173 | |
| 174 | export const DataApiProvider = () => |
| 175 | Provider.succeed(DataApi, { |
| 176 | stables: ["projectId", "branchId", "database"], |
| 177 | diff: Effect.fn(function* ({ olds, news, output }) { |
| 178 | yield* validateDataApiRemoval(olds, news); |
| 179 | if (isResolved(news)) yield* validateDataApi(news); |
| 180 | const scope = yield* authPlanScope(news); |
| 181 | if ( |
| 182 | output && |
| 183 | (!scope || |
| 184 | scope.projectId !== output.projectId || |
| 185 | scope.branchId !== output.branchId) |
| 186 | ) { |
| 187 | return { action: "replace", deleteFirst: true } as const; |
| 188 | } |
| 189 | if (!isResolved(news)) return; |
| 190 | const resolved = scope ?? (yield* resolveBranchScope(news)); |
| 191 | const database = yield* resolveDatabase(news, resolved); |
| 192 | const previous = output ?? (yield* resolveBranchScope(olds)); |
| 193 | if ( |
| 194 | resolved.projectId !== previous.projectId || |
| 195 | resolved.branchId !== previous.branchId || |
| 196 | database !== |
| 197 | (output?.database ?? (yield* resolveDatabase(olds, previous))) || |
| 198 | immutable.some((key) => news[key] !== olds[key]) |
| 199 | ) { |
| 200 | return { action: "replace", deleteFirst: true } as const; |
| 201 | } |
| 202 | if (output) { |
| 203 | const current = yield* observe({ ...resolved, database }); |
| 204 | if ( |
| 205 | !current || |
| 206 | (news.settings !== undefined && |
| 207 | !authSettingsMatch(current.settings ?? {}, news.settings)) |
| 208 | ) |
| 209 | return { action: "update" } as const; |
| 210 | } |
| 211 | }), |
| 212 | read: Effect.fn(function* ({ olds, output }) { |
| 213 | if ( |
| 214 | !output && |
| 215 | !(olds?.branch?.projectId && olds.branch.branchId) && |
| 216 | !olds?.project?.projectId |
| 217 | ) |
| 218 | return undefined; |
| 219 | const branch = output ?? (yield* resolveBranchScope(olds)); |
| 220 | const scope = output ?? { |
| 221 | ...branch, |
| 222 | database: yield* resolveDatabase(olds, branch), |
| 223 | }; |
| 224 | const current = yield* observe(scope); |
| 225 | if (!current) return undefined; |
| 226 | const attrs = attributes(scope, current); |
| 227 | return output ? attrs : Unowned(attrs); |
| 228 | }), |
| 229 | reconcile: Effect.fn(function* ({ news, output, olds }) { |
| 230 | yield* validateDataApiRemoval(olds, news); |
| 231 | yield* validateDataApi(news); |
| 232 | const branch = yield* resolveBranchScope(news); |
| 233 | const scope = { |
| 234 | ...branch, |
| 235 | database: yield* resolveDatabase(news, branch), |
| 236 | }; |
| 237 | if ( |
| 238 | output && |
| 239 | (scope.projectId !== output.projectId || |
| 240 | scope.branchId !== output.branchId || |
| 241 | scope.database !== output.database) |
| 242 | ) { |
| 243 | return yield* new InvalidDataApiConfiguration({ |
| 244 | message: |
| 245 | "Data API identity changed without replacement; refusing to mutate either endpoint", |
| 246 | }); |
| 247 | } |
| 248 | const request = { ...authRequest(scope), database_name: scope.database }; |
| 249 | let current = yield* observe(scope); |
| 250 | if (current && !output) |
| 251 | return yield* new OwnedBySomeoneElse({ |
| 252 | message: "Existing Data API requires explicit adoption", |
| 253 | resourceType: "Neon.DataApi", |
| 254 | }); |
| 255 | if ( |
| 256 | current && |
| 257 | olds === undefined && |
| 258 | immutable.some((key) => news[key] !== undefined) |
| 259 | ) { |
| 260 | return yield* new InvalidDataApiConfiguration({ |
| 261 | message: |
| 262 | "Neon does not expose existing Data API authentication settings; adopt without creation-only settings, then replace to configure authentication", |
| 263 | }); |
| 264 | } |
| 265 | if (!current) { |
| 266 | yield* Neon.createProjectBranchDataAPI({ |
| 267 | ...request, |
| 268 | auth_provider: news.authProvider, |
| 269 | jwks_url: news.jwksUrl, |
| 270 | provider_name: news.providerName, |
| 271 | jwt_audience: news.jwtAudience, |
| 272 | add_default_grants: news.addDefaultGrants ?? false, |
| 273 | skip_auth_schema: news.skipAuthSchema, |
| 274 | settings: news.settings, |
| 275 | }).pipe( |
| 276 | Effect.catchTag("Conflict", () => |
| 277 | Effect.fail( |
| 278 | new OwnedBySomeoneElse({ |
| 279 | message: |
| 280 | "Data API appeared during creation; explicit adoption is required", |
| 281 | resourceType: "Neon.DataApi", |
| 282 | }), |
| 283 | ), |
| 284 | ), |
| 285 | ); |
| 286 | current = yield* Neon.getProjectBranchDataAPI(request); |
| 287 | } |
| 288 | if ( |
| 289 | news.settings !== undefined && |
| 290 | !authSettingsMatch(current.settings ?? {}, news.settings) |
| 291 | ) { |
| 292 | yield* Neon.updateProjectBranchDataAPI({ |
| 293 | ...request, |
| 294 | settings: news.settings, |
| 295 | }); |
| 296 | } |
| 297 | return attributes(scope, yield* Neon.getProjectBranchDataAPI(request)); |
| 298 | }), |
| 299 | delete: Effect.fn(function* ({ output }) { |
| 300 | if (yield* observe(output)) { |
| 301 | yield* Neon.deleteProjectBranchDataAPI({ |
| 302 | ...authRequest(output), |
| 303 | database_name: output.database, |
| 304 | }).pipe(Effect.catchTag("NotFound", () => Effect.void)); |
| 305 | } |
| 306 | }), |
| 307 | }); |