1import * as Cloudflare from "@/Cloudflare/index.ts";
2import * as Alchemy from "@/index.ts";
3import * as Test from "@/Test/Alchemy";
4import * as secretsStore from "@distilled.cloud/cloudflare/secrets-store";
5import { expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Redacted from "effect/Redacted";
10import * as Schedule from "effect/Schedule";
11import * as HttpClient from "effect/http/HttpClient";
12import * as pathe from "pathe";
13import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment.ts";
14
15// `dev: true` runs local providers behind the RPC sidecar proxy by default,
16// matching the process topology of the real `alchemy dev` command (see
17// MakeOptions.sidecar in Test/Core.ts).
18const { test } = Test.make({
19 providers: Cloudflare.providers(),
20 dev: true,
21});
22
23const logLevel = Effect.provideService(
24 MinimumLogLevel,
25 process.env.DEBUG ? "Debug" : "Info",
26);
27
28class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
29 status: number;
30}> {}
31
32const getJsonReady = (url: string) =>
33 Effect.gen(function* () {
34 const client = yield* HttpClient.HttpClient;
35 const res = yield* client.get(url).pipe(
36 Effect.flatMap((res) =>
37 res.status === 200
38 ? Effect.succeed(res)
39 : Effect.fail(new WorkerNotReady({ status: res.status })),
40 ),
41 Effect.retry({
42 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
43 // Cap the backoff: an uncapped exponential over 10 recurs sums to
44 // ~8.5 minutes and turns a persistent non-200 into an apparent hang.
45 schedule: Schedule.max([
46 Schedule.min([
47 Schedule.exponential("500 millis"),
48 Schedule.spaced("2 seconds"),
49 ]),
50 Schedule.recurs(10),
51 ]),
52 }),
53 );
54 return yield* res.json;
55 }).pipe(Effect.orDie);
56
57/**
58 * Under `alchemy dev` the Store and Secret resources are emulated by the
59 * local providers (`dev:` ids, no cloud API calls): the Secret's reconcile
60 * seeds its value into the local workerd Secrets Store simulator through
61 * the admin gateway, and the worker's `secrets_store_secret` binding is
62 * lowered onto the local secrets-store service, so `env.SECRET.get()`
63 * returns the seeded value. A value update re-seeds the same key
64 * (idempotent overwrite) and the worker sees the new value after redeploy.
65 */
66test.provider(
67 "secrets store secret round-trips against the local simulator",
68 (stack) =>
69 Effect.gen(function* () {
70 yield* stack.destroy();
71
72 const deployAt = (value: string) =>
73 Effect.gen(function* () {
74 const store =
75 yield* Cloudflare.SecretsStore.Store("LocalSecretsStore");
76 const secret = yield* Cloudflare.SecretsStore.Secret("LocalApiKey", {
77 store,
78 value: Redacted.make(value),
79 });
80 const worker = yield* Cloudflare.Worker("secrets-local-worker", {
81 main: pathe.resolve(
82 import.meta.dirname,
83 "fixtures/secrets-local-worker.ts",
84 ),
85 env: { SECRET: secret },
86 });
87 return { store, secret, worker };
88 });
89
90 const v1 = yield* stack.deploy(deployAt("sk-local-value-1"));
91
92 // The local providers fabricate `dev:` ids — proof no cloud call ran
93 // — and the worker serves from the local dev proxy.
94 expect(v1.store.storeId).toMatch(/^dev:/);
95 expect(v1.secret.secretId).toMatch(/^dev:/);
96 expect(v1.secret.storeId).toBe(v1.store.storeId);
97 expect(v1.secret.status).toBe("active");
98 expect(v1.worker.url).toMatch(/^http:\/\/localhost:\d+$/);
99
100 const body1 = (yield* getJsonReady(`${v1.worker.url}/secret`)) as {
101 value: string;
102 };
103 expect(body1.value).toBe("sk-local-value-1");
104
105 // Update the value — same identity, re-seeded key.
106 const v2 = yield* stack.deploy(deployAt("sk-local-value-2"));
107 expect(v2.secret.secretId).toBe(v1.secret.secretId);
108 expect(v2.secret.storeId).toBe(v1.secret.storeId);
109
110 const body2 = (yield* getJsonReady(`${v2.worker.url}/secret`)) as {
111 value: string;
112 };
113 expect(body2.value).toBe("sk-local-value-2");
114
115 yield* stack.destroy();
116 }).pipe(logLevel),
117 {
118 tags: [
119 "provider:cloudflare",
120 "provider:cloudflare:secretsstore",
121 "provider:cloudflare:worker",
122 "local",
123 ],
124 timeout: 180_000,
125 },
126);
127
128/**
129 * `Alchemy.remote()` opts the resources OUT of local emulation: even under
130 * `alchemy dev` the store/secret are created on real Cloudflare (live
131 * providers) and the worker's binding proxies to the live secret remotely.
132 * Out-of-band reads through the cloud API prove the secret landed in the
133 * real store, and destroy (driven by the stamped `live` provider mode)
134 * removes it again.
135 */
136test.provider(
137 "Alchemy.remote() secret runs live in dev",
138 (stack) =>
139 Effect.gen(function* () {
140 yield* stack.destroy();
141
142 const deployed = yield* stack.deploy(
143 Effect.gen(function* () {
144 const store = yield* Cloudflare.SecretsStore.Store(
145 "RemoteSecretsStore",
146 ).pipe(Alchemy.remote());
147 const secret = yield* Cloudflare.SecretsStore.Secret(
148 "RemoteDevApiKey",
149 {
150 store,
151 value: Redacted.make("sk-remote-dev-value"),
152 },
153 ).pipe(Alchemy.remote());
154 const worker = yield* Cloudflare.Worker("secrets-remote-worker", {
155 main: pathe.resolve(
156 import.meta.dirname,
157 "fixtures/secrets-local-worker.ts",
158 ),
159 env: { SECRET: secret },
160 });
161 return { store, secret, worker };
162 }),
163 );
164
165 // Real (non-`dev:`) identities — the live providers ran.
166 expect(deployed.store.storeId).not.toMatch(/^dev:/);
167 expect(deployed.secret.secretId).not.toMatch(/^dev:/);
168 expect(deployed.secret.storeId).toBe(deployed.store.storeId);
169
170 // The locally-served worker reads the live secret through the
171 // remote-proxied binding.
172 const body = (yield* getJsonReady(`${deployed.worker.url}/secret`)) as {
173 value: string;
174 };
175 expect(body.value).toBe("sk-remote-dev-value");
176
177 // Out-of-band: the secret exists in the real store.
178 const { accountId } = yield* yield* CloudflareEnvironment;
179 const live = yield* secretsStore.getStoreSecret({
180 accountId,
181 storeId: deployed.secret.storeId,
182 secretId: deployed.secret.secretId,
183 });
184 expect(live.name).toBe(deployed.secret.secretName);
185
186 yield* stack.destroy();
187
188 // The secret was deleted from the cloud on destroy (its state row is
189 // stamped live, so the live provider handles the delete even in a
190 // dev run). The store itself is account-level infrastructure whose
191 // delete is an intentional no-op.
192 const gone = yield* secretsStore
193 .getStoreSecret({
194 accountId,
195 storeId: deployed.secret.storeId,
196 secretId: deployed.secret.secretId,
197 })
198 .pipe(
199 Effect.as(false),
200 Effect.catchTag("SecretNotFound", () => Effect.succeed(true)),
201 Effect.catchTag("StoreNotFound", () => Effect.succeed(true)),
202 );
203 expect(gone).toBe(true);
204 }).pipe(logLevel),
205 {
206 tags: [
207 "provider:cloudflare",
208 "provider:cloudflare:secretsstore",
209 "provider:cloudflare:worker",
210 "live",
211 ],
212 timeout: 180_000,
213 },
214);