8000 feat: allow binding individual secrets by tvanhens · Pull Request #393 · sam-goodwin/alchemy · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

feat: allow binding individual secrets #393

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions alchemy/src/cloudflare/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ import type { HyperdriveResource } from "./hyperdrive.ts";
import type { KVNamespaceResource } from "./kv-namespace.ts";
import type { PipelineResource } from "./pipeline.ts";
import type { QueueResource } from "./queue.ts";
import type { SecretsStore } from "./secrets-store.ts";
import type { VectorizeIndexResource } from "./vectorize-index.ts";
import type { VersionMetadata } from "./version-metadata.ts";
import type { WorkerStub } from "./worker-stub.ts";
import type { Worker, WorkerRef } from "./worker.ts";
import type { Workflow } from "./workflow.ts";
import type { Images } from "./images.ts";
import type { Secret as CloudflareSecret } from "./secret.ts";

export type Bindings = {
[bindingName: string]: Binding;
Expand All @@ -43,6 +43,7 @@ export type Binding =
| Ai
| AiGatewayResource
| Assets
| CloudflareSecret
| D1DatabaseResource
| DispatchNamespaceResource
| AnalyticsEngineDataset
Expand All @@ -53,7 +54,6 @@ export type Binding =
| PipelineResource
| QueueResource
| R2BucketResource
| SecretsStore<any>
| {
type: "kv_namespace";
id: string;
Expand Down Expand Up @@ -107,6 +107,7 @@ export type WorkerBindingSpec =
| WorkerBindingR2Bucket
| WorkerBindingSecretText
| WorkerBindingSecretsStore
| WorkerBindingSecretsStoreSecret
| WorkerBindingService
| WorkerBindingStaticContent
| WorkerBindingTailConsumer
Expand Down Expand Up @@ -313,6 +314,20 @@ export interface WorkerBindingSecretsStore {
secret_name: string;
}

/**
* Secrets Store Secret binding type for individual secrets
*/
export interface WorkerBindingSecretsStoreSecret {
/** The name of the binding */
name: string;
/** Type identifier for Secrets Store Secret binding */
type: "secrets_store_secret";
/** Store ID */
store_id: string;
/** Secret name */
secret_name: string;
}

/**
* Service binding type
*/
Expand Down
8000
38 changes: 15 additions & 23 deletions alchemy/src/cloudflare/bound.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Pipeline } from "cloudflare:pipelines";
import type { Secret } from "../secret.ts";
import type { Secret as CloudflareSecret } from "./secret.ts";
import type { AiGatewayResource as _AiGateway } from "./ai-gateway.ts";
import type { Ai as _Ai } from "./ai.ts";
import type { AnalyticsEngineDataset as _AnalyticsEngineDataset } from "./analytics-engine.ts";
Expand All @@ -14,7 +15,6 @@ import type { HyperdriveResource as _Hyperdrive } from "./hyperdrive.ts";
import type { Images as _Images } from "./images.ts";
import type { PipelineResource as _Pipeline } from "./pipeline.ts";
import type { QueueResource as _Queue } from "./queue.ts";
import type { SecretsStore as _SecretsStore } from "./secrets-store.ts";
import type { VectorizeIndexResource as _VectorizeIndex } from "./vectorize-index.ts";
import type { VersionMetadata as _VersionMetadata } from "./version-metadata.ts";
import type { Worker as _Worker, WorkerRef } from "./worker.ts";
Expand Down Expand Up @@ -45,20 +45,20 @@ export type Bound<T extends Binding> = T extends _DurableObjectNamespace<
? Hyperdrive
: T extends Secret
? string
: T extends Assets
? Service
: T extends _Workflow<infer P>
? Workflow<P>
: T extends D1DatabaseResource
? D1Database
: T extends DispatchNamespaceResource
? { get(name: string): Fetcher }
: T extends _VectorizeIndex
? VectorizeIndex
: T extends _Queue<infer Body>
? Queue<Body>
: T extends _SecretsStore<infer S>
? SecretsStoreBinding<S>
: T extends CloudflareSecret
? string
: T extends Assets
? Service
: T extends _Workflow<infer P>
? Workflow<P>
: T extends D1DatabaseResource
? D1Database
: T extends DispatchNamespaceResource
? { get(name: string): Fetcher }
: T extends _VectorizeIndex
? VectorizeIndex
: T extends _Queue<infer Body>
? Queue<Body>
: T extends _AnalyticsEngineDataset
? AnalyticsEngineDataset
: T extends _Pipeline<infer R>
Expand All @@ -78,11 +78,3 @@ export type Bound<T extends Binding> = T extends _DurableObjectNamespace<
: T extends Json<infer T>
? T
: Service;

interface SecretsStoreBinding<
S extends Record<string, Secret> | undefined = undefined,
> {
get(
key: (S extends Record<string, any> ? keyof S : never) | (string & {}),
): Promise<string>;
}
94 changes: 87 additions & 7 deletions alchemy/src/cloudflare/secret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ export function isSecret(resource: Resource): resource is Secret {
export interface Secret
extends Resource<"cloudflare::Secret">,
Omit<_SecretProps, "delete"> {
/**
* The binding type for Cloudflare Workers
*/
type: "secrets_store_secret";

/**
* The name of the secret
*/
Expand Down Expand Up @@ -190,6 +195,7 @@ const _Secret = Resource(
await insertSecret(api, props.store.id, name, props.value);

return this({
type: "secrets_store_secret",
name,
storeId: props.store.id,
store: props.store,
Expand All @@ -209,24 +215,98 @@ export async function insertSecret(
secretName: string,
secretValue: AlchemySecret,
): Promise<void> {
const response = await api.post(
// First try to create the secret
const createResponse = await api.post(
`/accounts/${api.accountId}/secrets_store/stores/${storeId}/secrets`,
[
{
name: secretName,
value: secretValue.unencrypted,
scopes: [],
scopes: ["workers"],
},
],
);

if (createResponse.ok) {
return; // Secret created successfully
}

// If creation failed, check if it's because the secret already exists
const createErrorData: any = await createResponse.json().catch(() => ({
errors: [{ message: createResponse.statusText }],
}));

const isAlreadyExists = createErrorData.errors?.some(
(error: any) =>
error.code === 10021 ||
error.message?.includes("secret_name_already_exists"),
);

if (isAlreadyExists) {
// Secret already exists, find its ID and update it
const secretId = await getSecretId(api, storeId, secretName);
if (!secretId) {
throw new Error(`Secret '${secretName}' not found in store`);
}

const updateResponse = await api.patch(
`/accounts/${api.accountId}/secrets_store/stores/${storeId}/secrets/${secretId}`,
{
value: secretValue.unencrypted,
scopes: ["workers"],
},
);

if (!updateResponse.ok) {
const updateErrorData: any = await updateResponse.json().catch(() => ({
errors: [{ message: updateResponse.statusText }],
}));
const updateErrorMessage =
updateErrorData.errors?.[0]?.message || updateResponse.statusText;
throw new Error(
`Error updating secret '${secretName}': ${updateErrorMessage}`,
);
}
} else {
// Some other error occurred during creation
const createErrorMessage =< 5D32 /span>
createErrorData.errors?.[0]?.message || createResponse.statusText;
throw new Error(
`Error creating secret '${secretName}': ${createErrorMessage}`,
);
Comment on lines +264 to +276
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call handleApiError instead of doing this manually each time.

}
}

/**
* Get the ID of a secret by its name
*/
async function getSecretId(
api: CloudflareApi,
storeId: string,
secretName: string,
): Promise<string | null> {
const response = await api.get(
`/accounts/${api.accountId}/secrets_store/stores/${storeId}/secrets`,
);

if (!response.ok) {
const errorData: any = await response.json().catch(() => ({
errors: [{ message: response.statusText }],
}));
const errorMessage = errorData.errors?.[0]?.message || response.statusText;
throw new Error(`Error creating secret '${secretName}': ${errorMessage}`);
return null;
}

const data = (await response.json()) as {
result: Array<{
id: string;
name: string;
created: string;
modified: string;
status: string;
}>;
success: boolean;
errors: any[];
};

const secret = data.result.find((s) => s.name === secretName);
return secret?.id || null;
}

/**
Expand Down
23 changes: 3 additions & 20 deletions alchemy/src/cloudflare/secrets-store.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import type { Context } from "../context.ts";
import { Resource, ResourceKind } from "../resource.ts";
import { bind } from "../runtime/bind.ts";
import { secret, type Secret } from "../secret.ts";
import { handleApiError } from "./api-error.ts";
import {
createCloudflareApi,
type CloudflareApi,
type CloudflareApiOptions,
} from "./api.ts";
import type { Bound } from "./bound.ts";

/**
* Properties for creating or updating a Secrets Store
Expand Down Expand Up @@ -66,11 +64,6 @@ export interface SecretsStore<
S extends Record<string, Secret> | undefined = undefined,
> extends Resource<"cloudflare::SecretsStore">,
Omit<SecretsStoreProps<S>, "delete"> {
/**
* The binding type for Cloudflare Workers
*/
type: "secrets_store";

/**
* The unique identifier of the secrets store
*/
Expand Down Expand Up @@ -98,10 +91,6 @@ export interface SecretsStore<
modifiedAt: number;
}

export type SecretsStoreWithBinding<
S extends Record<string, Secret> | undefined = undefined,
> = SecretsStore<S> & Bound<SecretsStore<S>>;

/**
* A Cloudflare Secrets Store is a secure, centralized location for storing account-level secrets.
*
Expand Down Expand Up @@ -162,7 +151,7 @@ export async function SecretsStore<
>(
name: string,
props: SecretsStoreProps<S> = {} as SecretsStoreProps<S>,
): Promise<SecretsStoreWithBinding<S>> {
): Promise<SecretsStore<S>> {
// Convert string values to Secret instances
const normalizedProps: SecretsStoreProps<S> = {
...props,
Expand All @@ -176,12 +165,7 @@ export async function SecretsStore<
: undefined,
};

const store = await _SecretsStore(name, normalizedProps);
const binding = await bind(store);
return {
...store,
get: binding.get,
} as SecretsStoreWithBinding<S>;
return _SecretsStore(name, normalizedProps);
}

const _SecretsStore = Resource("cloudflare::SecretsStore", async function <
Expand Down Expand Up @@ -254,7 +238,6 @@ const _SecretsStore = Resource("cloudflare::SecretsStore", async function <
}

return this({
type: "secrets_store",
id: storeId,
name: name,
secrets: props.secrets as S,
Expand Down Expand Up @@ -345,7 +328,7 @@ export async function insertSecrets<
const bulkPayload = batch.map(([name, secretValue]) => ({
name,
value: (secretValue as Secret).unencrypted,
scopes: [],
scopes: ["workers"],
}));

const bulkResponse = await api.post(
Expand Down
8 changes: 4 additions & 4 deletions alchemy/src/cloudflare/worker-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,12 +387,12 @@ export async function prepareWorkerMetadata<B extends Bindings>(
name: bindingName,
bucket_name: binding.name,
});
} else if (binding.type === "secrets_store") {
} else if (binding.type === "secrets_store_secret") {
meta.bindings.push({
type: "secrets_store",
type: "secrets_store_secret",
name: bindingName,
store_id: binding.id,
secret_name: bindingName,
store_id: binding.storeId,
secret_name: binding.name,
});
} else if (binding.type === "assets") {
meta.bindings.push({
Expand Down
16 changes: 6 additions & 10 deletions alchemy/src/cloudflare/wrangler.json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,16 +596,12 @@ function processBindings(
});
} else if (binding.type === "json") {
// TODO(sam): anything to do here? not sure wrangler.json supports this
} else if (binding.type === "secrets_store") {
if (binding.secrets) {
for (const [secretName, _secret] of Object.entries(binding.secrets)) {
secretsStoreSecrets.push({
binding: bindingName,
store_id: binding.id,
secret_name: secretName,
});
}
}
} else if (binding.type === "secrets_store_secret") {
secretsStoreSecrets.push({
binding: bindingName,
store_id: binding.storeId,
secret_name: binding.name,
});
} else if (binding.type === "dispatch_namespace") {
dispatchNamespaces.push({
binding: bindingName,
Expand Down
0