1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 GetAppsSecrets,
4 GetAppsSecretsFind,
5 CreateAppsSecret,
6 CreateAppsSecretsDelete,
7 type AppsSecret as StripeAppsSecret,
8 type GetAppsSecretsFindRequestScope,
9 type GetAppsSecretsRequestScope,
10 type CreateAppsSecretsDeleteRequestScope,
11 type CreateAppsSecretRequestScope,
12} from "@distilled.cloud/stripe/stripe";
13import * as Data from "effect/Data";
14import * as Effect from "effect/Effect";
15import { isResolved } from "../Diff.ts";
16import { createPhysicalName } from "../PhysicalName.ts";
17import * as Provider from "../Provider.ts";
18import { Resource } from "../Resource.ts";
19import type { Providers } from "./Providers.ts";
20import { isMissingStripeResource } from "./missing.ts";
21
22const NAME_MAX_LENGTH = 250;
23const LIST_PAGE_SIZE = 100;
24const LIST_MAX_PAGES = 100;
25
26/** The secret scope type. */
27export type AppsSecretScopeType = "account" | "user";
28
29/** Specifies the scoping of the secret. */
30export interface AppsSecretScope {
31 /**
32 * The secret scope type. `account` secrets are shared by all Dashboard
33 * users and the app backend. `user` secrets are visible to the app
34 * backend and one Dashboard user.
35 */
36 type: AppsSecretScopeType;
37 /**
38 * The user ID. Required when `type` is `user`, and must not be set when
39 * `type` is `account`.
40 */
41 user?: string;
42}
43
44export interface AppsSecretProps {
45 /**
46 * A name for the secret that's unique within the scope. If omitted, a
47 * unique name is generated from the stack, stage, and logical id.
48 * Changing it replaces the secret.
49 */
50 name?: string;
51 /**
52 * The plaintext secret value to be stored.
53 */
54 payload: string;
55 /**
56 * Specifies the scoping of the secret. Changing type or user replaces
57 * the secret.
58 * @default { type: "account" }
59 */
60 scope?: AppsSecretScope;
61 /**
62 * Unix timestamp after which the secret deletes.
63 */
64 expiresAt?: number;
65}
66
67export type AppsSecret = Resource<
68 "Stripe.AppsSecret",
69 AppsSecretProps,
70 {
71 /** Stripe secret id (`appsecret_…` or `secret_…`). */
72 id: string;
73 /** Name unique within the scope. */
74 name: string;
75 /** Scoping of the secret. */
76 scope: AppsSecretScope;
77 /** Unix timestamp after which the secret deletes, if set. */
78 expiresAt: number | undefined;
79 /** Whether this secret has been deleted. */
80 deleted: boolean | undefined;
81 /** Unix timestamp when the secret was created. */
82 created: number;
83 /** Whether the secret exists in live mode. */
84 livemode: boolean;
85 },
86 never,
87 Providers
88>;
89
90/**
91 * A Stripe Apps Secret — a named secret in the Stripe Apps Secret Store,
92 * used by UI extensions and app backends. Secrets are unique per
93 * `(name, scope)`. Create is upsert: posting the same name and scope
94 * replaces payload and expiry. Name and scope are identity; changing
95 * them replaces the secret. Destroy deletes it.
96 *
97 * Apps secrets have no metadata. Identity is name + scope. `list()`
98 * enumerates account-scoped secrets (user-scoped secrets require a user
99 * id and are not returned).
100 *
101 * @see https://docs.stripe.com/stripe-apps/store-secrets
102 *
103 * ### Creating a Secret
104 * **Example:** Account-scoped secret
105 * ```typescript
106 * const apiKey = yield* Stripe.AppsSecret("third-party-api", {
107 * name: "third-party-api",
108 * payload: "sk_live_example",
109 * scope: { type: "account" },
110 * });
111 * ```
112 *
113 * **Example:** Generated name
114 * ```typescript
115 * const apiKey = yield* Stripe.AppsSecret("third-party-api", {
116 * payload: "sk_live_example",
117 * });
118 * ```
119 *
120 * ### Updating a Secret
121 * **Example:** Rotate payload
122 * ```typescript
123 * const apiKey = yield* Stripe.AppsSecret("third-party-api", {
124 * name: "third-party-api",
125 * payload: "sk_live_rotated",
126 * scope: { type: "account" },
127 * });
128 * ```
129 *
130 * ### User-scoped secrets
131 * **Example:** Per-user OAuth token
132 * ```typescript
133 * const oauth = yield* Stripe.AppsSecret("user-oauth", {
134 * name: "oauth-token",
135 * payload: "tok_example",
136 * scope: { type: "user", user: "usr_123" },
137 * });
138 * ```
139 *
140 * @resource
141 * @product Secrets
142 */
143export const AppsSecret = Resource<AppsSecret>("Stripe.AppsSecret");
144
145export class AppsSecretNotResolved extends Data.TaggedError(
146 "Stripe.AppsSecretNotResolved",
147)<{
148 name: string;
149}> {}
150
151type AppsSecretAttributes = AppsSecret["Attributes"];
152
153const accountScope = (): AppsSecretScope => ({ type: "account" });
154
155const toScope = (scope: AppsSecretScope | undefined): AppsSecretScope => {
156 const type = scope?.type ?? "account";
157 if (type === "user" && scope?.user !== undefined) {
158 return { type, user: scope.user };
159 }
160 return { type };
161};
162
163const toWireScope = (
164 scope: AppsSecretScope,
165): CreateAppsSecretRequestScope &
166 GetAppsSecretsFindRequestScope &
167 GetAppsSecretsRequestScope &
168 CreateAppsSecretsDeleteRequestScope =>
169 scope.user !== undefined
170 ? { type: scope.type, user: scope.user }
171 : { type: scope.type };
172
173const fromObservedScope = (
174 scope: StripeAppsSecret["scope"],
175): AppsSecretScope =>
176 scope.user !== undefined
177 ? { type: scope.type, user: scope.user }
178 : { type: scope.type };
179
180const scopesEqual = (left: AppsSecretScope, right: AppsSecretScope): boolean =>
181 left.type === right.type &&
182 (left.user ?? undefined) === (right.user ?? undefined);
183
184const toName = (id: string, name: string | undefined, existing?: string) =>
185 Effect.gen(function* () {
186 return (
187 name ??
188 existing ??
189 (yield* createPhysicalName({ id, maxLength: NAME_MAX_LENGTH }))
190 );
191 });
192
193const toAttrs = (secret: StripeAppsSecret): AppsSecretAttributes => ({
194 id: secret.id,
195 name: secret.name,
196 scope: fromObservedScope(secret.scope),
197 expiresAt: secret.expires_at ?? undefined,
198 deleted: secret.deleted,
199 created: secret.created,
200 livemode: secret.livemode,
201});
202
203const isMissingSecret = isMissingStripeResource;
204
205const isPresent = (
206 secret: StripeAppsSecret | undefined,
207): secret is StripeAppsSecret =>
208 secret !== undefined && secret.deleted !== true;
209
210const findByName = (name: string, scope: AppsSecretScope) =>
211 GetAppsSecretsFind({
212 name,
213 scope: toWireScope(scope),
214 expand: ["payload"],
215 }).pipe(
216 Effect.map((secret) => (isPresent(secret) ? secret : undefined)),
217 Effect.catchIf(isMissingSecret, () => Effect.succeed(undefined)),
218 );
219
220const listByScope = Effect.fn(function* (scope: AppsSecretScope) {
221 const secrets: StripeAppsSecret[] = [];
222 let startingAfter: string | undefined;
223 for (let page = 0; page < LIST_MAX_PAGES; page++) {
224 const response = yield* GetAppsSecrets({
225 scope: toWireScope(scope),
226 limit: LIST_PAGE_SIZE,
227 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
228 });
229 secrets.push(...response.data);
230 if (!response.has_more || response.data.length === 0) {
231 break;
232 }
233 startingAfter = response.data[response.data.length - 1]?.id;
234 if (startingAfter === undefined) {
235 break;
236 }
237 }
238 return secrets.filter(isPresent);
239});
240
241const observe = Effect.fn(function* (input: {
242 id?: string;
243 name?: string;
244 scope: AppsSecretScope;
245}) {
246 if (input.name !== undefined) {
247 const byName = yield* findByName(input.name, input.scope);
248 if (byName !== undefined) return byName;
249 }
250 if (input.id !== undefined) {
251 const secrets = yield* listByScope(input.scope);
252 const byId = secrets.find((secret) => secret.id === input.id);
253 if (byId !== undefined) {
254 const expanded = yield* findByName(
255 byId.name,
256 fromObservedScope(byId.scope),
257 );
258 if (expanded !== undefined) return expanded;
259 return byId;
260 }
261 }
262 return undefined;
263});
264
265const shouldReplace = (
266 news: AppsSecretProps,
267 output: AppsSecretAttributes | undefined,
268): boolean => {
269 if (output === undefined) return false;
270 if (news.name !== undefined && news.name !== output.name) return true;
271 if (
272 news.scope !== undefined &&
273 !scopesEqual(toScope(news.scope), output.scope)
274 ) {
275 return true;
276 }
277 return false;
278};
279
280export const AppsSecretProvider = () =>
281 Provider.succeed(AppsSecret, {
282 stables: ["name", "scope", "livemode"],
283
284 diff: Effect.fn(function* ({ news, output }) {
285 if (!isResolved(news)) return undefined;
286 if (shouldReplace(news, output)) {
287 return { action: "replace" as const };
288 }
289 return undefined;
290 }),
291
292 read: Effect.fn(function* ({ output, olds }) {
293 const name =
294 output?.name ??
295 (typeof olds?.name === "string" ? olds.name : undefined);
296 const scope = toScope(
297 output?.scope ??
298 (olds?.scope !== undefined && typeof olds.scope === "object"
299 ? (olds.scope as AppsSecretScope)
300 : undefined),
301 );
302 const existing = yield* observe({
303 id: output?.id,
304 name,
305 scope,
306 });
307 if (existing === undefined) return undefined;
308 // No metadata. Identity is name + scope; a match is treated as owned.
309 return toAttrs(existing);
310 }),
311
312 list: Effect.fn(function* () {
313 // Apps secrets have no metadata. List is account-scoped secrets;
314 // user-scoped rows require a user id and are not enumerated. Deleted
315 // rows are omitted so they do not re-enter nuke.
316 const secrets = yield* listByScope(accountScope());
317 return secrets.map(toAttrs);
318 }),
319
320 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
321 const name = yield* toName(id, news.name, output?.name);
322 const scope = toScope(news.scope ?? output?.scope);
323 const desiredExpiresAt = news.expiresAt;
324
325 let current = yield* observe({
326 id: output?.id,
327 name,
328 scope,
329 });
330 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
331 current = undefined;
332 }
333
334 const put = (idempotencyKey: string) =>
335 CreateAppsSecret({
336 name,
337 payload: news.payload,
338 scope: toWireScope(scope),
339 ...(desiredExpiresAt !== undefined
340 ? { expires_at: desiredExpiresAt }
341 : {}),
342 }).pipe(
343 withRequestOptions({
344 idempotencyKey,
345 }),
346 );
347
348 if (current === undefined) {
349 current = yield* put(`alchemy-apps-secret-${instanceId}`).pipe(
350 Effect.catchIf(
351 (e) => e._tag === "InvalidRequestError" || e._tag === "Conflict",
352 (e) =>
353 observe({ name, scope }).pipe(
354 Effect.flatMap((found) =>
355 found !== undefined ? Effect.succeed(found) : Effect.fail(e),
356 ),
357 ),
358 ),
359 );
360 }
361
362 if (current === undefined) {
363 return yield* new AppsSecretNotResolved({ name });
364 }
365
366 const observedPayload = current.payload ?? undefined;
367 const payloadChanged =
368 observedPayload !== undefined && observedPayload !== news.payload;
369 const expiresChanged =
370 (current.expires_at ?? undefined) !== desiredExpiresAt;
371
372 if (!payloadChanged && !expiresChanged) {
373 return toAttrs(current);
374 }
375
376 current = yield* put(`alchemy-apps-secret-${instanceId}-sync`);
377 return toAttrs(current);
378 }),
379
380 delete: Effect.fn(function* ({ output }) {
381 yield* CreateAppsSecretsDelete({
382 name: output.name,
383 scope: toWireScope(output.scope),
384 }).pipe(Effect.catchIf(isMissingSecret, () => Effect.void));
385 }),
386 });