1import * as Cloudflare from "@/Cloudflare";
2import * as Test from "@/Test/Alchemy";
3import * as secretsStore from "@distilled.cloud/cloudflare/secrets-store";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as Redacted from "effect/Redacted";
7import { MinimumLogLevel } from "effect/References";
8
9const { test } = Test.make({ providers: Cloudflare.providers() });
10
11const logLevel = Effect.provideService(
12 MinimumLogLevel,
13 process.env.DEBUG ? "Debug" : "Info",
14);
15
16const GATEWAY_ID = "alchemy-test-aigw-providerkey";
17const PROVIDER_SLUG = "openai";
18
19test.provider(
20 "provisions a Secret + GatewayProvider with the correct naming contract",
21 (stack) =>
22 Effect.gen(function* () {
23 yield* stack.destroy();
24
25 const deployed = yield* stack.deploy(
26 Effect.gen(function* () {
27 const store = yield* Cloudflare.SecretsStore.Store("PkStore");
28 const gateway = yield* Cloudflare.AI.Gateway("PkGateway", {
29 id: GATEWAY_ID,
30 storeId: store.storeId,
31 });
32 // ProviderKey composes Secret + GatewayProvider, deriving the secret
33 // name from `{gatewayId}_{providerSlug}_{alias}`.
34 return yield* Cloudflare.AI.ProviderKey("PkKey", {
35 store,
36 gatewayId: gateway.gatewayId,
37 providerSlug: PROVIDER_SLUG,
38 value: Redacted.make("alchemy-test-not-a-real-key"),
39 });
40 }),
41 );
42
43 // The secret must be named exactly `{gatewayId}_{providerSlug}_{alias}`.
44 const expectedName = `${GATEWAY_ID}_${PROVIDER_SLUG}_default`;
45 expect(deployed.secret.secretName).toEqual(expectedName);
46 // The secret must be scoped to `ai_gateway` for BYOK resolution.
47 expect(deployed.secret.scopes).toContain("ai_gateway");
48
49 // The gateway provider must reference the backing secret's id.
50 expect(deployed.gatewayProvider.secretId).toEqual(
51 deployed.secret.secretId,
52 );
53 // The alias carried through is `"default"` (the omitted-alias default).
54 expect(deployed.gatewayProvider.alias).toEqual("default");
55 expect(deployed.gatewayProvider.providerSlug).toEqual(PROVIDER_SLUG);
56 expect(deployed.gatewayProvider.gatewayId).toEqual(GATEWAY_ID);
57
58 // Redeploying identical props is a no-op — same resource IDs.
59 const redeployed = yield* stack.deploy(
60 Effect.gen(function* () {
61 const store = yield* Cloudflare.SecretsStore.Store("PkStore");
62 const gateway = yield* Cloudflare.AI.Gateway("PkGateway", {
63 id: GATEWAY_ID,
64 storeId: store.storeId,
65 });
66 return yield* Cloudflare.AI.ProviderKey("PkKey", {
67 store,
68 gatewayId: gateway.gatewayId,
69 providerSlug: PROVIDER_SLUG,
70 value: Redacted.make("alchemy-test-not-a-real-key"),
71 });
72 }),
73 );
74 expect(redeployed.secret.secretId).toEqual(deployed.secret.secretId);
75 expect(redeployed.gatewayProvider.providerConfigId).toEqual(
76 deployed.gatewayProvider.providerConfigId,
77 );
78
79 yield* stack.destroy();
80 }).pipe(logLevel),
81 {
82 tags: [
83 "provider:cloudflare",
84 "provider:cloudflare:ai",
85 "provider:cloudflare:secretsstore",
86 "live",
87 ],
88 },
89);
90
91test.provider(
92 "explicit alias threads through the naming contract",
93 (stack) =>
94 Effect.gen(function* () {
95 yield* stack.destroy();
96
97 const deployed = yield* stack.deploy(
98 Effect.gen(function* () {
99 const store = yield* Cloudflare.SecretsStore.Store("PkAliasStore");
100 const gateway = yield* Cloudflare.AI.Gateway("PkAliasGateway", {
101 id: GATEWAY_ID + "-alias",
102 storeId: store.storeId,
103 });
104 return yield* Cloudflare.AI.ProviderKey("PkAliasKey", {
105 store,
106 gatewayId: gateway.gatewayId,
107 providerSlug: "anthropic",
108 alias: "evals",
109 value: Redacted.make("alchemy-test-not-a-real-key"),
110 });
111 }),
112 );
113
114 expect(deployed.secret.secretName).toEqual(
115 `${GATEWAY_ID}-alias_anthropic_evals`,
116 );
117 expect(deployed.gatewayProvider.alias).toEqual("evals");
118
119 yield* stack.destroy();
120 }).pipe(logLevel),
121 {
122 tags: [
123 "provider:cloudflare",
124 "provider:cloudflare:ai",
125 "provider:cloudflare:secretsstore",
126 "live",
127 ],
128 },
129);
130
131test.provider(
132 "value rotation updates in place; alias change cascades a replacement",
133 (stack) =>
134 Effect.gen(function* () {
135 yield* stack.destroy();
136
137 const program = (opts: { value: string; alias?: string }) =>
138 Effect.gen(function* () {
139 const store = yield* Cloudflare.SecretsStore.Store("PkRotateStore");
140 const gateway = yield* Cloudflare.AI.Gateway("PkRotateGateway", {
141 id: GATEWAY_ID + "-rotate",
142 storeId: store.storeId,
143 });
144 return yield* Cloudflare.AI.ProviderKey("PkRotateKey", {
145 store,
146 gatewayId: gateway.gatewayId,
147 providerSlug: PROVIDER_SLUG,
148 alias: opts.alias,
149 value: Redacted.make(opts.value),
150 });
151 });
152
153 const initial = yield* stack.deploy(
154 program({ value: "alchemy-test-key-v1" }),
155 );
156
157 // Rotating the key value is an in-place update: the secret keeps its
158 // id, so the provider config it feeds is untouched.
159 const rotated = yield* stack.deploy(
160 program({ value: "alchemy-test-key-v2" }),
161 );
162 expect(rotated.secret.secretId).toEqual(initial.secret.secretId);
163 expect(rotated.gatewayProvider.providerConfigId).toEqual(
164 initial.gatewayProvider.providerConfigId,
165 );
166
167 // Changing the alias renames the secret — a replacement (new id) —
168 // and must cascade: the provider config is replaced (delete-first)
169 // and re-pointed at the new secret.
170 const renamed = yield* stack.deploy(
171 program({ value: "alchemy-test-key-v2", alias: "rotated" }),
172 );
173 expect(renamed.secret.secretName).toEqual(
174 `${GATEWAY_ID}-rotate_${PROVIDER_SLUG}_rotated`,
175 );
176 expect(renamed.secret.secretId).not.toEqual(initial.secret.secretId);
177 expect(renamed.gatewayProvider.providerConfigId).not.toEqual(
178 initial.gatewayProvider.providerConfigId,
179 );
180 expect(renamed.gatewayProvider.alias).toEqual("rotated");
181 expect(renamed.gatewayProvider.secretId).toEqual(renamed.secret.secretId);
182
183 // The replaced secret is reclaimed — no orphan left in the store.
184 const oldSecret = yield* secretsStore
185 .getStoreSecret({
186 accountId: initial.secret.accountId,
187 storeId: initial.secret.storeId,
188 secretId: initial.secret.secretId,
189 })
190 .pipe(
191 Effect.catchTag("SecretNotFound", () => Effect.succeed(undefined)),
192 );
193 expect(oldSecret).toBeUndefined();
194
195 yield* stack.destroy();
196 }).pipe(logLevel),
197 {
198 tags: [
199 "provider:cloudflare",
200 "provider:cloudflare:ai",
201 "provider:cloudflare:secretsstore",
202 "live",
203 ],
204 },
205);
206
207test.provider(
208 "destroying the provider key removes the secret from the surviving store",
209 (stack) =>
210 Effect.gen(function* () {
211 yield* stack.destroy();
212
213 const deployed = yield* stack.deploy(
214 Effect.gen(function* () {
215 const store = yield* Cloudflare.SecretsStore.Store("PkDeleteStore");
216 const gateway = yield* Cloudflare.AI.Gateway("PkDeleteGateway", {
217 id: GATEWAY_ID + "-delete",
218 storeId: store.storeId,
219 });
220 return yield* Cloudflare.AI.ProviderKey("PkDeleteKey", {
221 store,
222 gatewayId: gateway.gatewayId,
223 providerSlug: PROVIDER_SLUG,
224 value: Redacted.make("alchemy-test-not-a-real-key"),
225 });
226 }),
227 );
228
229 expect(deployed.secret.secretName).toEqual(
230 `${GATEWAY_ID}-delete_${PROVIDER_SLUG}_default`,
231 );
232
233 yield* stack.destroy();
234
235 // The account-level Secrets Store is adopted, not created, so it
236 // survives `destroy` — the helper's Secret resource is the only thing
237 // that reclaims the BYOK secret. Verify the secret is actually gone
238 // (an orphaned secret is the one real leak this composition could
239 // introduce). A surviving store means a missing secret surfaces as
240 // `SecretNotFound`, not `StoreNotFound`.
241 const secretAfter = yield* secretsStore
242 .getStoreSecret({
243 accountId: deployed.secret.accountId,
244 storeId: deployed.secret.storeId,
245 secretId: deployed.secret.secretId,
246 })
247 .pipe(
248 Effect.catchTag("SecretNotFound", () => Effect.succeed(undefined)),
249 );
250 expect(secretAfter).toBeUndefined();
251 }).pipe(logLevel),
252 {
253 tags: [
254 "provider:cloudflare",
255 "provider:cloudflare:ai",
256 "provider:cloudflare:secretsstore",
257 "live",
258 ],
259 },
260);