| 1 | import { Credentials } from "@distilled.cloud/stripe"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Redacted from "effect/Redacted"; |
| 4 | import { createPhysicalName } from "../PhysicalName.ts"; |
| 5 | import * as Provider from "../Provider.ts"; |
| 6 | import { Resource } from "../Resource.ts"; |
| 7 | import type { Providers } from "./Providers.ts"; |
| 8 | |
| 9 | /** |
| 10 | * Stripe restricted-key permission. Names match the Dashboard RAK catalog |
| 11 | * (`resource_read` / `resource_write`). Write implies read. |
| 12 | * |
| 13 | * Stripe does not currently expose a public API to mint restricted keys |
| 14 | * (`POST /v2/iam/api_keys` is Stripe Apps private preview). Bindings still |
| 15 | * declare the permissions they need so a future mint can honor them. |
| 16 | */ |
| 17 | export type StripePermission = |
| 18 | | "accounts_read" |
| 19 | | "accounts_write" |
| 20 | | "apple_pay_domains_read" |
| 21 | | "apple_pay_domains_write" |
| 22 | | "apps_secrets_read" |
| 23 | | "apps_secrets_write" |
| 24 | | "billing_meters_read" |
| 25 | | "billing_meters_write" |
| 26 | | "billing_portal_read" |
| 27 | | "billing_portal_write" |
| 28 | | "checkout_sessions_read" |
| 29 | | "checkout_sessions_write" |
| 30 | | "coupons_read" |
| 31 | | "coupons_write" |
| 32 | | "credit_grants_read" |
| 33 | | "credit_grants_write" |
| 34 | | "customers_read" |
| 35 | | "customers_write" |
| 36 | | "entitlements_read" |
| 37 | | "entitlements_write" |
| 38 | | "files_read" |
| 39 | | "files_write" |
| 40 | | "issuing_read" |
| 41 | | "issuing_write" |
| 42 | | "payment_links_read" |
| 43 | | "payment_links_write" |
| 44 | | "payment_method_configurations_read" |
| 45 | | "payment_method_configurations_write" |
| 46 | | "payment_method_domains_read" |
| 47 | | "payment_method_domains_write" |
| 48 | | "plans_read" |
| 49 | | "plans_write" |
| 50 | | "prices_read" |
| 51 | | "prices_write" |
| 52 | | "products_read" |
| 53 | | "products_write" |
| 54 | | "promotion_codes_read" |
| 55 | | "promotion_codes_write" |
| 56 | | "radar_read" |
| 57 | | "radar_write" |
| 58 | | "shipping_rates_read" |
| 59 | | "shipping_rates_write" |
| 60 | | "tax_read" |
| 61 | | "tax_write" |
| 62 | | "terminal_read" |
| 63 | | "terminal_write" |
| 64 | | "webhook_endpoints_read" |
| 65 | | "webhook_endpoints_write"; |
| 66 | |
| 67 | export interface RestrictedApiKeyProps { |
| 68 | /** |
| 69 | * Display name. If omitted, a unique name is generated from the stack, |
| 70 | * stage, and logical id. |
| 71 | */ |
| 72 | name?: string; |
| 73 | /** |
| 74 | * Optional Dashboard-created restricted key (`rk_test_…` / `rk_live_…`). |
| 75 | * When omitted, the account secret from {@link Credentials} is used — |
| 76 | * Stripe has no public API to mint restricted keys. |
| 77 | */ |
| 78 | value?: string; |
| 79 | /** |
| 80 | * Permissions this key should have. Additional permissions are merged |
| 81 | * in from bindings. |
| 82 | */ |
| 83 | permissions?: StripePermission[]; |
| 84 | } |
| 85 | |
| 86 | /** |
| 87 | * Binding contract for {@link RestrictedApiKey}. A capability contributes |
| 88 | * the Stripe permissions it needs; they are merged onto the token. |
| 89 | */ |
| 90 | export type RestrictedApiKeyBinding = { |
| 91 | permissions?: StripePermission[]; |
| 92 | }; |
| 93 | |
| 94 | export type RestrictedApiKey = Resource< |
| 95 | "Stripe.RestrictedApiKey", |
| 96 | RestrictedApiKeyProps, |
| 97 | { |
| 98 | /** Stable id (logical physical name — Stripe has no key object id). */ |
| 99 | id: string; |
| 100 | /** Display name. */ |
| 101 | name: string; |
| 102 | /** Secret or restricted key value. Returned only to Alchemy state. */ |
| 103 | value: Redacted.Redacted<string>; |
| 104 | /** Union of prop and binding permissions. */ |
| 105 | permissions: StripePermission[]; |
| 106 | }, |
| 107 | RestrictedApiKeyBinding, |
| 108 | Providers |
| 109 | >; |
| 110 | |
| 111 | /** |
| 112 | * A least-privilege Stripe API key for a Function/Worker host. |
| 113 | * |
| 114 | * HTTP bindings call `token.bind(capability, { permissions })` the same |
| 115 | * way Cloudflare HTTP bindings attach policies to an AccountApiToken. |
| 116 | * Stripe restricted keys (`rk_…`) can only be created in the Dashboard |
| 117 | * today — `POST /v2/iam/api_keys` is Stripe Apps private preview. Until |
| 118 | * that API is generally available, this resource stores the collected |
| 119 | * permissions and injects either a user-supplied RAK or the account |
| 120 | * secret key. |
| 121 | * |
| 122 | * ### Creating a token |
| 123 | * **Example:** Host token, permissions from bindings |
| 124 | * ```typescript |
| 125 | * const token = yield* Stripe.RestrictedApiKey("ApiToken"); |
| 126 | * yield* token.bind`RetrieveProduct`({ |
| 127 | * permissions: ["products_read"], |
| 128 | * }); |
| 129 | * ``` |
| 130 | * |
| 131 | * **Example:** Dashboard-created restricted key |
| 132 | * ```typescript |
| 133 | * const token = yield* Stripe.RestrictedApiKey("ApiToken", { |
| 134 | * value: process.env.STRIPE_RESTRICTED_KEY, |
| 135 | * permissions: ["customers_read"], |
| 136 | * }); |
| 137 | * ``` |
| 138 | * |
| 139 | * @resource |
| 140 | * @product Secrets |
| 141 | */ |
| 142 | export const RestrictedApiKey = Resource<RestrictedApiKey>( |
| 143 | "Stripe.RestrictedApiKey", |
| 144 | ); |
| 145 | |
| 146 | const resolveName = (id: string, name: string | undefined) => |
| 147 | Effect.gen(function* () { |
| 148 | return name ?? (yield* createPhysicalName({ id })); |
| 149 | }); |
| 150 | |
| 151 | export const collectPermissions = ( |
| 152 | props: StripePermission[] | undefined, |
| 153 | bindings: { data: RestrictedApiKeyBinding }[], |
| 154 | ): StripePermission[] => { |
| 155 | const set = new Set<StripePermission>(props ?? []); |
| 156 | for (const binding of bindings) { |
| 157 | for (const permission of binding.data.permissions ?? []) { |
| 158 | set.add(permission); |
| 159 | } |
| 160 | } |
| 161 | return [...set].sort(); |
| 162 | }; |
| 163 | |
| 164 | export const RestrictedApiKeyProvider = () => |
| 165 | Provider.succeed(RestrictedApiKey, { |
| 166 | stables: ["id"], |
| 167 | |
| 168 | diff: Effect.fn(function* () { |
| 169 | return undefined; |
| 170 | }), |
| 171 | |
| 172 | read: Effect.fn(function* ({ output }) { |
| 173 | return output; |
| 174 | }), |
| 175 | |
| 176 | // Logical token only — there is no Stripe list of keys we created. |
| 177 | list: Effect.fn(function* () { |
| 178 | return []; |
| 179 | }), |
| 180 | |
| 181 | reconcile: Effect.fn(function* ({ id, news, output, bindings }) { |
| 182 | const props = news ?? {}; |
| 183 | const name = yield* resolveName(id, props.name); |
| 184 | const permissions = collectPermissions(props.permissions, bindings); |
| 185 | // TODO: Mint a real restricted key once Stripe publishes a public |
| 186 | // create-key API. `POST /v2/iam/api_keys` (Stripe Apps IAM preview) |
| 187 | // 404s as of 2026-08. Pass `permissions` (props + bindings) as the |
| 188 | // key's scopes, persist the returned `rk_…` as `value`, and teach |
| 189 | // list/delete to enumerate/revoke it. Until then this is a logical |
| 190 | // token: Dashboard `news.value` or the account secret. |
| 191 | const value = |
| 192 | props.value !== undefined |
| 193 | ? Redacted.make(props.value) |
| 194 | : yield* Effect.gen(function* () { |
| 195 | const resolve = yield* Credentials; |
| 196 | const cfg = yield* resolve; |
| 197 | return Redacted.make(Redacted.value(cfg.apiKey)); |
| 198 | }); |
| 199 | return { |
| 200 | id: output?.id ?? name, |
| 201 | name, |
| 202 | value, |
| 203 | permissions, |
| 204 | }; |
| 205 | }), |
| 206 | |
| 207 | delete: Effect.fn(function* () { |
| 208 | // Dashboard-created RAKs are not deleted; we never minted a Stripe |
| 209 | // object for the default account-secret path. |
| 210 | }), |
| 211 | }); |