| 1 | import { |
| 2 | Credentials, |
| 3 | DEFAULT_API_BASE_URL, |
| 4 | type Config as StripeCredentialsConfig, |
| 5 | } from "@distilled.cloud/stripe"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Option from "effect/Option"; |
| 9 | import * as Redacted from "effect/Redacted"; |
| 10 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 11 | import * as HttpClient from "effect/http/HttpClient"; |
| 12 | import * as Binding from "../Binding.ts"; |
| 13 | import * as Output from "../Output.ts"; |
| 14 | import { type ResourceLike } from "../Resource.ts"; |
| 15 | import type { RuntimeContext } from "../RuntimeContext.ts"; |
| 16 | import { RestrictedApiKey, type StripePermission } from "./RestrictedApiKey.ts"; |
| 17 | |
| 18 | /** |
| 19 | * Shared scaffolding for HTTP Stripe bindings. |
| 20 | * |
| 21 | * Stripe has no native Worker binding. Secrets and resource ids are |
| 22 | * registered via `yield* token.value` / `yield* resource.id` so |
| 23 | * RuntimeContext.set + packEnvValueKeepRedacted emit `secret_text`. |
| 24 | * Do not push raw `{ type: "secret_text", text }` items — the Worker |
| 25 | * provider forwards those to the wire un-resolved. Do not |
| 26 | * `Output.asEffect` + unwrap at bind time — that resolves before the |
| 27 | * RestrictedApiKey exists. |
| 28 | * |
| 29 | * NOT exported from `index.ts`. |
| 30 | */ |
| 31 | |
| 32 | export interface StripeIdResource { |
| 33 | readonly LogicalId: string; |
| 34 | readonly id: unknown; |
| 35 | } |
| 36 | |
| 37 | export interface StripeAuth { |
| 38 | authorize: <A, E>( |
| 39 | eff: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>, |
| 40 | ) => Effect.Effect<A, E, RuntimeContext>; |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Accessor for a bound RestrictedApiKey value. The `yield*` of `token.value` |
| 45 | * is RuntimeContext.set — it does not resolve the secret at plan time. |
| 46 | */ |
| 47 | const stripeRuntimeLayer = ( |
| 48 | credentials: Effect.Effect<StripeCredentialsConfig>, |
| 49 | ): Layer.Layer<Credentials | HttpClient.HttpClient> => |
| 50 | Layer.succeed(Credentials, credentials).pipe( |
| 51 | Layer.provideMerge(FetchHttpClient.layer), |
| 52 | ); |
| 53 | |
| 54 | export const authorizeWith = |
| 55 | (token: { value: Effect.Effect<Redacted.Redacted<string>> }) => |
| 56 | <A, E>( |
| 57 | eff: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>, |
| 58 | ): Effect.Effect<A, E, RuntimeContext> => |
| 59 | token.value.pipe( |
| 60 | Effect.flatMap((apiKey) => |
| 61 | eff.pipe( |
| 62 | Effect.provide( |
| 63 | stripeRuntimeLayer( |
| 64 | Effect.succeed({ |
| 65 | apiKey, |
| 66 | apiBaseUrl: DEFAULT_API_BASE_URL, |
| 67 | } satisfies StripeCredentialsConfig), |
| 68 | ), |
| 69 | ), |
| 70 | ), |
| 71 | ), |
| 72 | ) as Effect.Effect<A, E, RuntimeContext>; |
| 73 | |
| 74 | export const makeStripeAuth = (options: { |
| 75 | credentials: Effect.Effect<StripeCredentialsConfig> | undefined; |
| 76 | http: HttpClient.HttpClient | undefined; |
| 77 | }): StripeAuth => ({ |
| 78 | authorize: <A, E>( |
| 79 | eff: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>, |
| 80 | ): Effect.Effect<A, E, RuntimeContext> => { |
| 81 | if (options.credentials === undefined || options.http === undefined) { |
| 82 | return Effect.die( |
| 83 | "Stripe HTTP binding missing Credentials or HttpClient at plan time", |
| 84 | ) as Effect.Effect<A, E, RuntimeContext>; |
| 85 | } |
| 86 | return eff.pipe( |
| 87 | Effect.provide( |
| 88 | Layer.mergeAll( |
| 89 | Layer.succeed(Credentials, options.credentials), |
| 90 | Layer.succeed(HttpClient.HttpClient, options.http), |
| 91 | ), |
| 92 | ), |
| 93 | ) as Effect.Effect<A, E, RuntimeContext>; |
| 94 | }, |
| 95 | }); |
| 96 | |
| 97 | export const resolveStripeAuth = Effect.gen(function* () { |
| 98 | const credentials = yield* Effect.serviceOption(Credentials).pipe( |
| 99 | Effect.map(Option.getOrUndefined), |
| 100 | ); |
| 101 | const http = yield* Effect.serviceOption(HttpClient.HttpClient).pipe( |
| 102 | Effect.map(Option.getOrUndefined), |
| 103 | ); |
| 104 | return makeStripeAuth({ credentials, http }); |
| 105 | }); |
| 106 | |
| 107 | /** |
| 108 | * Bind an Output (or plain string) at init. `yield*` this at bind time so |
| 109 | * RuntimeContext.set runs; the inner Effect is the runtime getter. |
| 110 | * Never unwrap the Output to a string here. |
| 111 | */ |
| 112 | export const asStringEffect = ( |
| 113 | value: unknown, |
| 114 | ): Effect.Effect<Effect.Effect<string>> => { |
| 115 | if (typeof value === "string") { |
| 116 | return Effect.succeed(Effect.succeed(value)); |
| 117 | } |
| 118 | if (Output.isOutput(value)) { |
| 119 | return value.asEffect() as Effect.Effect<Effect.Effect<string>>; |
| 120 | } |
| 121 | if (Effect.isEffect(value)) { |
| 122 | return Effect.succeed(value as Effect.Effect<string>); |
| 123 | } |
| 124 | return Effect.die("Stripe binding expected a resolved resource id"); |
| 125 | }; |
| 126 | |
| 127 | /** Like {@link asStringEffect} for optional string fields. */ |
| 128 | export const asOptionalStringEffect = ( |
| 129 | value: unknown, |
| 130 | ): Effect.Effect<Effect.Effect<string | undefined>> => { |
| 131 | if (value === undefined || value === null || value === "") { |
| 132 | return Effect.succeed(Effect.succeed(undefined)); |
| 133 | } |
| 134 | return asStringEffect(value).pipe( |
| 135 | Effect.map((inner) => |
| 136 | inner.pipe(Effect.map((s) => (s.length > 0 ? s : undefined))), |
| 137 | ), |
| 138 | ); |
| 139 | }; |
| 140 | |
| 141 | /** |
| 142 | * Mint (or reuse) the host's RestrictedApiKey and attach permissions. |
| 143 | * Returns a runtime accessor for the key value (`yield* token.value`). |
| 144 | * That Output is the plan-to-runtime transport on every host — do not |
| 145 | * also `host.bind({ env })`. |
| 146 | */ |
| 147 | const missingHostToken = { |
| 148 | value: Effect.die("Stripe token accessed without a host") as Effect.Effect< |
| 149 | Redacted.Redacted<string> |
| 150 | >, |
| 151 | }; |
| 152 | |
| 153 | export const attachStripeToken = ( |
| 154 | resource: ResourceLike | undefined, |
| 155 | permissions: readonly StripePermission[], |
| 156 | bindId: string, |
| 157 | ) => |
| 158 | Effect.gen(function* () { |
| 159 | const host = yield* Binding.Host; |
| 160 | if (host === undefined) { |
| 161 | return missingHostToken; |
| 162 | } |
| 163 | const Token = yield* RestrictedApiKey; |
| 164 | const token = yield* Token(`${host.LogicalId}StripeToken`, {}); |
| 165 | if (!globalThis.__ALCHEMY_RUNTIME__) { |
| 166 | const sid = |
| 167 | resource !== undefined |
| 168 | ? `${bindId}:${resource.LogicalId}` |
| 169 | : `${bindId}:${host.LogicalId}`; |
| 170 | yield* token.bind(sid, { |
| 171 | permissions: [...permissions], |
| 172 | }); |
| 173 | } |
| 174 | return { value: yield* token.value }; |
| 175 | }); |
| 176 | |
| 177 | export const makeHttpStripeIdBinding = < |
| 178 | I extends Record<IdField, string>, |
| 179 | A, |
| 180 | E, |
| 181 | IdField extends string, |
| 182 | >(options: { |
| 183 | tag: string; |
| 184 | operation: ( |
| 185 | input: I, |
| 186 | ) => Effect.Effect<A, E, Credentials | HttpClient.HttpClient>; |
| 187 | idField: IdField; |
| 188 | permissions: readonly StripePermission[]; |
| 189 | }) => |
| 190 | Effect.gen(function* () { |
| 191 | const ambient = yield* resolveStripeAuth; |
| 192 | |
| 193 | return Effect.fn(function* (resource: StripeIdResource) { |
| 194 | const host = yield* Binding.Host; |
| 195 | const bound = yield* attachStripeToken( |
| 196 | resource as unknown as ResourceLike, |
| 197 | options.permissions, |
| 198 | options.tag, |
| 199 | ); |
| 200 | const id = yield* asStringEffect(resource.id); |
| 201 | const auth = |
| 202 | host !== undefined ? authorizeWith(bound) : ambient.authorize; |
| 203 | |
| 204 | return Effect.fn(`${options.tag}(${resource.LogicalId})`)(function* ( |
| 205 | request?: Omit<I, IdField>, |
| 206 | ) { |
| 207 | return yield* auth( |
| 208 | options.operation({ |
| 209 | ...(request as object), |
| 210 | [options.idField]: yield* id, |
| 211 | } as I), |
| 212 | ); |
| 213 | }); |
| 214 | }); |
| 215 | }); |
| 216 | |
| 217 | export const makeHttpStripeAccountBinding = <I, A, E>(options: { |
| 218 | tag: string; |
| 219 | operation: ( |
| 220 | input: I, |
| 221 | ) => Effect.Effect<A, E, Credentials | HttpClient.HttpClient>; |
| 222 | permissions: readonly StripePermission[]; |
| 223 | }) => |
| 224 | Effect.gen(function* () { |
| 225 | const ambient = yield* resolveStripeAuth; |
| 226 | |
| 227 | return Effect.fn(function* () { |
| 228 | const host = yield* Binding.Host; |
| 229 | const bound = yield* attachStripeToken( |
| 230 | undefined, |
| 231 | options.permissions, |
| 232 | options.tag, |
| 233 | ); |
| 234 | const auth = |
| 235 | host !== undefined ? authorizeWith(bound) : ambient.authorize; |
| 236 | |
| 237 | return Effect.fn(options.tag)(function* (request: I) { |
| 238 | return yield* auth(options.operation(request)); |
| 239 | }); |
| 240 | }); |
| 241 | }); |