1import * as Effect from "effect/Effect";
2import type * as Redacted from "effect/Redacted";
3import type * as HttpClient from "effect/http/HttpClient";
4import type { RuntimeContext } from "../../RuntimeContext.ts";
5import { Self } from "../../Self.ts";
6import { AccountApiToken } from "../ApiToken/AccountApiToken.ts";
7import type { PermissionGroupRef } from "../ApiToken/Common.ts";
8import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9import type { Credentials } from "../Credentials.ts";
10import { authorizeWith } from "../HttpClientUtils.ts";
11import type { Namespace } from "./Namespace.ts";
12import { NamespaceError } from "./NamespaceTypes.ts";
13
14/**
15 * Shared scaffolding for the HTTP-backed KV services.
16 *
17 * Creates a scoped {@link AccountApiToken}, binds its `value` / `accountId`
18 * into the host Worker at deploy time, then delegates to `makeClient` with
19 * the bound token and the namespace's `namespaceId`.
20 */
21export const makeHttpKVNamespaceBinding = <Client>(options: {
22 permissionGroups: PermissionGroup[];
23 makeClient: (token: HttpToken, namespaceId: Effect.Effect<string>) => Client;
24}) =>
25 Effect.gen(function* () {
26 const Token = yield* AccountApiToken;
27 const self = yield* Self;
28 const env = yield* CloudflareEnvironment;
29
30 return Effect.fn(function* (namespace: Namespace) {
31 const { accountId } = yield* env;
32 const token = yield* Token(`${self.LogicalId}Token`);
33 if (!globalThis.__ALCHEMY_RUNTIME__) {
34 yield* token.bind`${namespace.LogicalId}`({
35 policies: [
36 {
37 effect: "allow",
38 permissionGroups: options.permissionGroups,
39 resources: {
40 [`com.cloudflare.api.account.${accountId}`]: "*",
41 },
42 },
43 ],
44 });
45 }
46 const bound = {
47 value: yield* token.value,
48 accountId: yield* token.accountId,
49 } satisfies HttpToken;
50 const namespaceId = yield* namespace.namespaceId;
51 return options.makeClient(bound, namespaceId);
52 });
53 });
54
55export interface HttpToken {
56 value: Effect.Effect<Redacted.Redacted<string>>;
57 accountId: Effect.Effect<string>;
58}
59
60export interface HttpScope {
61 accountId: string;
62 namespaceId: string;
63}
64
65/**
66 * Injectable auth for the KV HTTP client builders. Both the scoped-token
67 * (`*Http`) and current-credentials (`*Local`) variants supply an `authorize`
68 * (which provides `Credentials` + `HttpClient` to a raw SDK op) and an
69 * `accountId`, so the client builders are agnostic to how creds are obtained.
70 */
71export interface KVAuth {
72 authorize: <A, E>(
73 eff: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>,
74 ) => Effect.Effect<A, E, RuntimeContext>;
75 accountId: Effect.Effect<string>;
76}
77
78/** Build a scoped-token {@link KVAuth} from a bound {@link HttpToken}. */
79export const makeKVAuth = (token: HttpToken): KVAuth => ({
80 authorize: authorizeWith(token),
81 accountId: token.accountId,
82});
83
84const KV_HTTP_PERMISSION_GROUPS: PermissionGroupRef[] = [
85 "Workers KV Storage Read",
86 "Workers KV Storage Write",
87];
88
89type PermissionGroup = (typeof KV_HTTP_PERMISSION_GROUPS)[number];
90
91/** Resolve the account and namespace id once per operation. */
92export const makeKVHttpScope = (
93 auth: KVAuth,
94 namespaceId: Effect.Effect<string>,
95): Effect.Effect<HttpScope> =>
96 Effect.gen(function* () {
97 const accountId = yield* auth.accountId;
98 const id = yield* namespaceId;
99 return { accountId, namespaceId: id };
100 });
101
102export const toKVNamespaceError = (error: unknown): NamespaceError =>
103 new NamespaceError({
104 message:
105 typeof error === "object" && error !== null && "message" in error
106 ? String((error as { message: unknown }).message)
107 : "Unknown KV error",
108 cause: error instanceof Error ? error : new Error(String(error)),
109 });