1import * as aisearch from "@distilled.cloud/cloudflare/aisearch";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Predicate from "effect/Predicate";
5import * as Redacted from "effect/Redacted";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8
9import { isResolved } from "../../Diff.ts";
10import { createPhysicalName } from "../../PhysicalName.ts";
11import * as Provider from "../../Provider.ts";
12import { Resource } from "../../Resource.ts";
13import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
14import type { Providers } from "../Providers.ts";
15
16const TypeId = "Cloudflare.AI.SearchToken" as const;
17type TypeId = typeof TypeId;
18
19export type SearchTokenProps = {
20 /**
21 * Display name for the service token. Mutable in place.
22 * If omitted, a unique name is generated from the app, stage, and
23 * logical ID.
24 * @default ${app}-${id}-${stage}-${suffix}
25 */
26 name?: string;
27 /**
28 * Id of the underlying Cloudflare API token AI Search authenticates
29 * with when syncing the data source. The token must carry the
30 * "AI Search Index Engine" permission group — Cloudflare validates it
31 * on create/update and rejects tokens without it.
32 */
33 cfApiId: string;
34 /**
35 * Plaintext value of the underlying Cloudflare API token. Write-only —
36 * Cloudflare never returns it, so rotation is detected against the
37 * previously-persisted props rather than observed cloud state.
38 */
39 cfApiKey: Redacted.Redacted<string>;
40 /**
41 * Whether this is a legacy (account-level instance) token.
42 * @default false
43 */
44 legacy?: boolean;
45};
46
47export type TokenAttributes = {
48 /**
49 * AI Search service token id (a UUID). Stable across updates.
50 */
51 id: string;
52 /**
53 * The Cloudflare account the token belongs to.
54 */
55 accountId: string;
56 /**
57 * Display name of the service token.
58 */
59 name: string;
60 /**
61 * Id of the underlying Cloudflare API token.
62 */
63 cfApiId: string;
64 /**
65 * Whether the token is enabled.
66 */
67 enabled: boolean | undefined;
68 /**
69 * Whether this is a legacy (account-level instance) token.
70 */
71 legacy: boolean | undefined;
72 /**
73 * When the token was created.
74 */
75 createdAt: string | undefined;
76 /**
77 * When the token was last modified.
78 */
79 modifiedAt: string | undefined;
80 /**
81 * Who created the token.
82 */
83 createdBy: string | undefined;
84 /**
85 * Who last modified the token.
86 */
87 modifiedBy: string | undefined;
88};
89
90export type SearchToken = Resource<
91 TypeId,
92 SearchTokenProps,
93 TokenAttributes,
94 never,
95 Providers
96>;
97
98/**
99 * A Cloudflare.AI. Search service token — the credential AI Search uses to
100 * access your data source (R2 bucket, Vectorize index, Workers AI) when
101 * indexing.
102 *
103 * The token wraps an existing Cloudflare API token (`cfApiId` +
104 * `cfApiKey`). That API token must carry the "AI Search Index Engine"
105 * permission group — Cloudflare validates the credential on create and
106 * update and rejects tokens without it. Pair it with
107 * `Cloudflare.ApiToken.AccountApiToken` to mint the underlying API token in the
108 * same stack, then reference the service token's `id` from an AI Search
109 * instance's `tokenId` prop.
110 * ### Creating a Token
111 * **Example:** Minting the underlying API token in the same stack
112 * ```typescript
113 * const apiToken = yield* Cloudflare.ApiToken.AccountApiToken("SearchTokenSource", {
114 * policies: [
115 * {
116 * effect: "allow",
117 * permissionGroups: ["AI Search Index Engine"],
118 * resources: { [`com.cloudflare.api.account.${accountId}`]: "*" },
119 * },
120 * ],
121 * });
122 * const token = yield* Cloudflare.AI.SearchToken("SearchToken", {
123 * cfApiId: apiToken.tokenId,
124 * cfApiKey: apiToken.value,
125 * });
126 * ```
127 *
128 * ### Using the Token from a SearchInstance
129 * **Example:** Wiring the token into an AI Search instance
130 * ```typescript
131 * const search = yield* Cloudflare.AI.Search("Search", {
132 * source: bucket,
133 * tokenId: token.id,
134 * });
135 * ```
136 *
137 * @see https://developers.cloudflare.com/ai-search/
138 *
139 * @resource
140 * @product AI Search
141 * @category AI
142 */
143export const SearchToken = Resource<SearchToken>(TypeId, {
144 aliases: ["Cloudflare.AiSearch.Token"],
145});
146
147/**
148 * Returns true if the given value is a SearchToken resource.
149 */
150export const isSearchToken = (value: unknown): value is SearchToken =>
151 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
152
153export const SearchTokenProvider = () =>
154 Provider.succeed(SearchToken, {
155 stables: ["id", "accountId", "createdAt", "createdBy"],
156 diff: Effect.fn(function* ({ news, output }) {
157 const { accountId } = yield* yield* CloudflareEnvironment;
158 if (!isResolved(news)) return undefined;
159 if ((output?.accountId ?? accountId) !== accountId) {
160 return { action: "replace" } as const;
161 }
162 // Everything (name, credentials, legacy flag) is mutable via PUT —
163 // let the engine apply its default update logic.
164 return undefined;
165 }),
166 read: Effect.fn(function* ({ id, output, olds }) {
167 const { accountId } = yield* yield* CloudflareEnvironment;
168 const acct = output?.accountId ?? accountId;
169 const observed = output?.id
170 ? yield* getToken(acct, output.id)
171 : // Cold read (lost state): the display name is deterministic, so
172 // scan the token list for it.
173 yield* findTokenByName(acct, yield* createTokenName(id, olds?.name));
174 return observed ? toAttributes(observed, acct) : undefined;
175 }),
176 reconcile: Effect.fn(function* ({ id, news, olds, output }) {
177 const { accountId } = yield* yield* CloudflareEnvironment;
178 const acct = output?.accountId ?? accountId;
179 const name = yield* createTokenName(id, news.name);
180
181 // Observe — `output.id` is a cache, not a guarantee: a missing
182 // token falls through to "missing" and we recreate. Without an id
183 // (greenfield or adoption-by-name), scan the list for the
184 // deterministic display name.
185 const observed = output?.id
186 ? yield* getToken(acct, output.id)
187 : yield* findTokenByName(acct, name);
188
189 const body = {
190 name,
191 cfApiId: news.cfApiId as string,
192 cfApiKey: Redacted.value(news.cfApiKey as Redacted.Redacted<string>),
193 // Cloudflare defaults an omitted `legacy` to `true`, and legacy
194 // (account-level instance) tokens cannot be managed via the per-token
195 // routes — GET/DELETE `/ai-search/tokens/{id}` return 401, so they can
196 // never be read or deleted, leaking on every teardown. Force
197 // non-legacy unless the caller explicitly opts in.
198 legacy: news.legacy ?? false,
199 };
200
201 if (!observed) {
202 // Ensure — create with the full desired body. A freshly minted
203 // underlying API token propagates eventually-consistently, so a
204 // transient `InvalidTokenCredentials` (code 7012) is retried
205 // briefly before being treated as a real validation failure.
206 const created = yield* aisearch
207 .createToken({ accountId: acct, ...body })
208 .pipe(retryTokenPropagation);
209 return toAttributes(created, acct);
210 }
211
212 // Sync — Cloudflare never returns `cfApiKey`, so the secret cannot
213 // be diffed against observed state; fall back to the previously
214 // persisted props for the key only. Everything else diffs against
215 // the observed cloud state.
216 const keyRotated =
217 olds === undefined ||
218 olds.cfApiKey === undefined ||
219 !isResolved(olds.cfApiKey) ||
220 Redacted.value(olds.cfApiKey) !== body.cfApiKey;
221 const dirty =
222 keyRotated ||
223 observed.name !== name ||
224 observed.cfApiId !== body.cfApiId ||
225 (news.legacy !== undefined &&
226 (observed.legacy ?? undefined) !== news.legacy);
227 if (!dirty) {
228 return toAttributes(observed, acct);
229 }
230
231 // The update is a PUT and always requires the full credential pair.
232 const updated = yield* aisearch
233 .updateToken({ accountId: acct, id: observed.id, ...body })
234 .pipe(retryTokenPropagation);
235 return toAttributes(updated, acct);
236 }),
237 list: () =>
238 // Service tokens are account-scoped (`/accounts/{id}/ai-search/tokens`),
239 // not nested under an instance — one paginated list per account.
240 Effect.gen(function* () {
241 const { accountId } = yield* yield* CloudflareEnvironment;
242 return yield* aisearch.listTokens
243 .pages({ accountId, perPage: 50 })
244 .pipe(
245 Stream.runCollect,
246 Effect.map((chunk) =>
247 Array.from(chunk).flatMap((page) =>
248 // The list item shape matches the create/read response, so the
249 // emitted Attributes are identical to `read` (the write-only
250 // `cfApiKey` is never part of Attributes anyway).
251 (page.result ?? []).map((t) => toAttributes(t, accountId)),
252 ),
253 ),
254 // AI Search isn't enabled on every account — the route is gated
255 // rather than returning an empty list. Treat it as "none".
256 Effect.catchTag("InvalidRoute", () => Effect.succeed([])),
257 );
258 }),
259 delete: Effect.fn(function* ({ output }) {
260 // A missing token (already deleted) is success. An AI Search
261 // instance that referenced this token may still be deleting
262 // asynchronously — Cloudflare rejects the delete with
263 // `token_in_use_by_instances` until the instance is gone, so ride
264 // that out briefly.
265 yield* aisearch
266 .deleteToken({ accountId: output.accountId, id: output.id })
267 .pipe(
268 Effect.retry({
269 while: (e) => e._tag === "TokenInUseByInstances",
270 // Allow brief asynchronous teardown, but surface a token still
271 // referenced by another instance instead of stalling cleanup.
272 schedule: Schedule.spaced("5 seconds"),
273 times: 8,
274 }),
275 Effect.catchTag("TokenNotFound", () => Effect.void),
276 );
277 }),
278 });
279
280type ObservedToken = aisearch.CreateTokenResponse;
281
282/**
283 * Read a token by id, mapping "gone" (`TokenNotFound`, Cloudflare error
284 * code 7075) to `undefined`. The read response declares every field
285 * optional, so observed identity fields are re-asserted from the request.
286 */
287const getToken = (accountId: string, id: string) =>
288 aisearch.readToken({ accountId, id }).pipe(
289 Effect.map((t): ObservedToken => ({
290 id: t.id ?? id,
291 name: t.name ?? "",
292 cfApiId: t.cfApiId ?? "",
293 createdAt: t.createdAt ?? "",
294 modifiedAt: t.modifiedAt ?? "",
295 createdBy: t.createdBy,
296 enabled: t.enabled,
297 legacy: t.legacy,
298 modifiedBy: t.modifiedBy,
299 })),
300 Effect.catchTag("TokenNotFound", () => Effect.succeed(undefined)),
301 );
302
303/**
304 * Scan the account's service tokens for an exact display-name match
305 * (used for cold reads and greenfield creates after lost state). The
306 * `search` query param chokes on long strings (generated physical names
307 * can hit the limit), so this streams all pages and filters client-side.
308 */
309const findTokenByName = (accountId: string, name: string) =>
310 aisearch.listTokens.items({ accountId, perPage: 50 }).pipe(
311 Stream.filter((t) => t.name === name),
312 Stream.runHead,
313 Effect.map(Option.getOrUndefined),
314 );
315
316const createTokenName = (id: string, name: string | undefined) =>
317 Effect.gen(function* () {
318 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
319 });
320
321/**
322 * A freshly minted underlying API token may not have propagated yet when
323 * AI Search validates it — ride out transient `InvalidTokenCredentials`
324 * (code 7012) with a short, bounded retry. Genuinely invalid credentials
325 * still fail after the retries are exhausted.
326 */
327const retryTokenPropagation = <A, E extends { _tag: string }, R>(
328 effect: Effect.Effect<A, E, R>,
329) =>
330 effect.pipe(
331 Effect.retry({
332 while: (e) => e._tag === "InvalidTokenCredentials",
333 schedule: Schedule.exponential("1 second"),
334 times: 6,
335 }),
336 );
337
338const toAttributes = (
339 token: ObservedToken | aisearch.UpdateTokenResponse,
340 accountId: string,
341): TokenAttributes => ({
342 id: token.id,
343 accountId,
344 name: token.name,
345 cfApiId: token.cfApiId,
346 enabled: token.enabled ?? undefined,
347 legacy: token.legacy ?? undefined,
348 createdAt: token.createdAt || undefined,
349 modifiedAt: token.modifiedAt || undefined,
350 createdBy: token.createdBy ?? undefined,
351 modifiedBy: token.modifiedBy ?? undefined,
352});