1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
8import type { Providers } from "../Providers.ts";
9
10const TypeId = "Cloudflare.Access.KeyConfiguration" as const;
11type TypeId = typeof TypeId;
12
13export type KeyConfigurationProps = {
14 /**
15 * The number of days between automatic Access service key rotations.
16 * Cloudflare accepts values between 21 and 372 days.
17 *
18 * Mutable — converged in place via PUT.
19 */
20 keyRotationIntervalDays: number;
21};
22
23export type KeyConfigurationAttributes = {
24 /** Cloudflare account the key configuration belongs to. */
25 accountId: string;
26 /** The number of days between key rotations. */
27 keyRotationIntervalDays: number | undefined;
28 /** The number of days until the next key rotation. */
29 daysUntilNextRotation: number | undefined;
30 /** The timestamp of the previous key rotation, if one has happened. */
31 lastKeyRotationAt: string | undefined;
32 /**
33 * The rotation interval the account had before Alchemy first managed it.
34 * Restored on destroy, so deleting the resource puts the account back
35 * the way it was found. `undefined` when Cloudflare reported no interval
36 * at adoption time — destroy then leaves the current interval in place.
37 */
38 initialKeyRotationIntervalDays: number | undefined;
39};
40
41export type KeyConfiguration = Resource<
42 TypeId,
43 KeyConfigurationProps,
44 KeyConfigurationAttributes,
45 never,
46 Providers
47>;
48
49/**
50 * The Cloudflare Zero Trust Access service-key rotation configuration for an
51 * account (`/accounts/{account_id}/access/keys`).
52 *
53 * The key configuration is an account singleton — it always exists, so this
54 * resource never creates or deletes anything physical. Reconcile PUTs the
55 * rotation interval when the observed value differs from the desired one;
56 * destroy restores the interval the account had before Alchemy first managed
57 * it (captured as `initialKeyRotationIntervalDays`).
58 * ### Managing the rotation interval
59 * **Example:** Rotate Access service keys every 30 days
60 * ```typescript
61 * const keys = yield* Cloudflare.Access.KeyConfiguration("Keys", {
62 * keyRotationIntervalDays: 30,
63 * });
64 * ```
65 *
66 * **Example:** Inspect rotation status
67 * ```typescript
68 * const keys = yield* Cloudflare.Access.KeyConfiguration("Keys", {
69 * keyRotationIntervalDays: 90,
70 * });
71 * // keys.daysUntilNextRotation, keys.lastKeyRotationAt
72 * ```
73 *
74 * @see https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/keys/
75 *
76 * @resource
77 * @product Access
78 * @category Cloudflare One (Zero Trust)
79 */
80export const KeyConfiguration = Resource<KeyConfiguration>(TypeId);
81
82/**
83 * Returns true if the given value is an KeyConfiguration resource.
84 */
85export const isKeyConfiguration = (value: unknown): value is KeyConfiguration =>
86 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
87
88export const KeyConfigurationProvider = () =>
89 Provider.succeed(KeyConfiguration, {
90 nuke: { singleton: true },
91 stables: ["accountId", "initialKeyRotationIntervalDays"],
92
93 list: Effect.fn(function* () {
94 const { accountId } = yield* yield* CloudflareEnvironment;
95 // Per-account singleton: the key configuration always exists with a
96 // Cloudflare default and there is no enumeration API — read the one
97 // instance and return it as a one-element array. The observed
98 // interval becomes the value restored on destroy (adoption read).
99 const observed = yield* zeroTrust.getAccessKey({ accountId });
100 return [
101 toAttributes(
102 accountId,
103 observed,
104 observed.keyRotationIntervalDays ?? undefined,
105 ),
106 ];
107 }),
108
109 diff: Effect.fn(function* ({ output }) {
110 // The configuration is an account singleton — its identity is the
111 // account. Moving accounts replaces (old account's interval is
112 // restored as the old instance deletes).
113 const { accountId } = yield* yield* CloudflareEnvironment;
114 if ((output?.accountId ?? accountId) !== accountId) {
115 return { action: "replace" } as const;
116 }
117 return undefined;
118 }),
119
120 read: Effect.fn(function* ({ output }) {
121 const { accountId } = yield* yield* CloudflareEnvironment;
122 const acct = output?.accountId ?? accountId;
123 const observed = yield* zeroTrust.getAccessKey({ accountId: acct });
124 // The configuration is a singleton that always exists with a
125 // Cloudflare default — there is nothing to "own", so a cold read
126 // adopts freely (never `Unowned`). The observed interval at adoption
127 // time becomes the value restored on destroy.
128 const initial =
129 output !== undefined
130 ? output.initialKeyRotationIntervalDays
131 : (observed.keyRotationIntervalDays ?? undefined);
132 return toAttributes(acct, observed, initial);
133 }),
134
135 reconcile: Effect.fn(function* ({ news, output }) {
136 const { accountId } = yield* yield* CloudflareEnvironment;
137
138 // 1. Observe — the configuration always exists; read the live value.
139 const observed = yield* zeroTrust.getAccessKey({ accountId });
140
141 // 2. Capture — the pre-management interval, restored on destroy.
142 // `output` (including an adoption read) already carries it;
143 // otherwise this is our first touch and the observed interval is
144 // the account's original.
145 const initial =
146 output !== undefined
147 ? output.initialKeyRotationIntervalDays
148 : (observed.keyRotationIntervalDays ?? undefined);
149
150 // 3. Sync — PUT only when the observed interval differs.
151 if (observed.keyRotationIntervalDays === news.keyRotationIntervalDays) {
152 return toAttributes(accountId, observed, initial);
153 }
154 const updated = yield* zeroTrust.putAccessKey({
155 accountId,
156 keyRotationIntervalDays: news.keyRotationIntervalDays,
157 });
158 return toAttributes(accountId, updated, initial);
159 }),
160
161 delete: Effect.fn(function* ({ output }) {
162 const { accountId, initialKeyRotationIntervalDays } = output;
163 // Nothing physical to delete — restore the pre-management interval.
164 // If Cloudflare reported no interval at adoption time there is
165 // nothing to restore to; leave the current value in place.
166 if (initialKeyRotationIntervalDays === undefined) return;
167 const observed = yield* zeroTrust.getAccessKey({ accountId });
168 // Skip the PUT when the live value already matches (idempotent
169 // re-delete after a crashed run).
170 if (observed.keyRotationIntervalDays === initialKeyRotationIntervalDays) {
171 return;
172 }
173 yield* zeroTrust.putAccessKey({
174 accountId,
175 keyRotationIntervalDays: initialKeyRotationIntervalDays,
176 });
177 }),
178 });
179
180const toAttributes = (
181 accountId: string,
182 observed: zeroTrust.GetAccessKeyResponse | zeroTrust.PutAccessKeyResponse,
183 initialKeyRotationIntervalDays: number | undefined,
184): KeyConfigurationAttributes => ({
185 accountId,
186 keyRotationIntervalDays: observed.keyRotationIntervalDays ?? undefined,
187 daysUntilNextRotation: observed.daysUntilNextRotation ?? undefined,
188 lastKeyRotationAt: observed.lastKeyRotationAt ?? undefined,
189 initialKeyRotationIntervalDays,
190});