1import {
2 Credentials,
3 DEFAULT_API_BASE_URL,
4 type Config as StripeCredentialsConfig,
5} from "@distilled.cloud/stripe";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Redacted from "effect/Redacted";
10import * as FetchHttpClient from "effect/http/FetchHttpClient";
11import * as HttpClient from "effect/http/HttpClient";
12import * as Binding from "../Binding.ts";
13import * as Output from "../Output.ts";
14import { type ResourceLike } from "../Resource.ts";
15import type { RuntimeContext } from "../RuntimeContext.ts";
16import { 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
32export interface StripeIdResource {
33 readonly LogicalId: string;
34 readonly id: unknown;
35}
36
37export 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 */
47const 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
54export 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
74export 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
97export 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 */
112export 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. */
128export 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 */
147const missingHostToken = {
148 value: Effect.die("Stripe token accessed without a host") as Effect.Effect<
149 Redacted.Redacted<string>
150 >,
151};
152
153export 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
177export 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
217export 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 });