@homeflare/alchemy 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/README.md +25 -0
  2. package/dist/caddy/access-forward-auth.d.ts +56 -0
  3. package/dist/caddy/access-forward-auth.d.ts.map +1 -0
  4. package/dist/caddy/index.d.ts +6 -0
  5. package/dist/caddy/index.d.ts.map +1 -1
  6. package/dist/caddy/index.js +169 -75
  7. package/dist/caddy/index.js.map +10 -7
  8. package/dist/cloudflare/mcp-server-api.d.ts +135 -0
  9. package/dist/cloudflare/mcp-server-api.d.ts.map +1 -0
  10. package/dist/cloudflare/mcp-server-form.d.ts +116 -0
  11. package/dist/cloudflare/mcp-server-form.d.ts.map +1 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/launchd/file-converge.d.ts +70 -0
  15. package/dist/launchd/file-converge.d.ts.map +1 -0
  16. package/dist/launchd/file-identity.d.ts +32 -0
  17. package/dist/launchd/file-identity.d.ts.map +1 -0
  18. package/dist/launchd/host-file-form.d.ts +2 -0
  19. package/dist/launchd/host-file-form.d.ts.map +1 -1
  20. package/dist/launchd/host-file-lifecycle.d.ts +11 -24
  21. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  22. package/dist/launchd/host-file.d.ts.map +1 -1
  23. package/dist/launchd/index.js +173 -120
  24. package/dist/launchd/index.js.map +12 -10
  25. package/dist/launchd/local-runner.d.ts.map +1 -1
  26. package/dist/launchd/runner.d.ts +17 -2
  27. package/dist/launchd/runner.d.ts.map +1 -1
  28. package/dist/launchd/sudo-runner.d.ts +11 -0
  29. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  30. package/dist/linux/index.js.map +3 -3
  31. package/dist/linux/sudo-listing.d.ts +47 -0
  32. package/dist/linux/sudo-listing.d.ts.map +1 -0
  33. package/dist/proxmox/credential-literals.d.ts +1 -1
  34. package/dist/proxmox/credential-literals.d.ts.map +1 -1
  35. package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
  36. package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
  37. package/dist/proxmox/generated/pbs.d.ts +7 -0
  38. package/dist/proxmox/generated/pbs.d.ts.map +1 -1
  39. package/dist/proxmox/generated/pve.d.ts +7 -0
  40. package/dist/proxmox/generated/pve.d.ts.map +1 -1
  41. package/dist/proxmox/index.d.ts +1 -1
  42. package/dist/proxmox/index.d.ts.map +1 -1
  43. package/dist/proxmox/index.js +2 -2
  44. package/dist/proxmox/index.js.map +9 -9
  45. package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
  46. package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
  47. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  48. package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
  49. package/dist/release/archive.d.ts +11 -0
  50. package/dist/release/archive.d.ts.map +1 -0
  51. package/dist/release/binary-claim.d.ts +53 -0
  52. package/dist/release/binary-claim.d.ts.map +1 -0
  53. package/dist/release/binary-diff.d.ts +38 -0
  54. package/dist/release/binary-diff.d.ts.map +1 -0
  55. package/dist/release/binary-form.d.ts +118 -0
  56. package/dist/release/binary-form.d.ts.map +1 -0
  57. package/dist/release/binary-lifecycle.d.ts +55 -0
  58. package/dist/release/binary-lifecycle.d.ts.map +1 -0
  59. package/dist/release/binary-read.d.ts +11 -0
  60. package/dist/release/binary-read.d.ts.map +1 -0
  61. package/dist/release/binary.d.ts +68 -0
  62. package/dist/release/binary.d.ts.map +1 -0
  63. package/dist/release/catalog.d.ts +76 -0
  64. package/dist/release/catalog.d.ts.map +1 -0
  65. package/dist/release/checksums.d.ts +22 -0
  66. package/dist/release/checksums.d.ts.map +1 -0
  67. package/dist/release/declared-pins.d.ts +4 -0
  68. package/dist/release/declared-pins.d.ts.map +1 -0
  69. package/dist/release/download.d.ts +44 -0
  70. package/dist/release/download.d.ts.map +1 -0
  71. package/dist/release/fake-deploy.d.ts +48 -0
  72. package/dist/release/fake-deploy.d.ts.map +1 -0
  73. package/dist/release/fake-engine.d.ts +8 -0
  74. package/dist/release/fake-engine.d.ts.map +1 -0
  75. package/dist/release/fake-install.d.ts +39 -0
  76. package/dist/release/fake-install.d.ts.map +1 -0
  77. package/dist/release/fake-provider.d.ts +58 -0
  78. package/dist/release/fake-provider.d.ts.map +1 -0
  79. package/dist/release/fake-release.d.ts +95 -0
  80. package/dist/release/fake-release.d.ts.map +1 -0
  81. package/dist/release/index.d.ts +24 -0
  82. package/dist/release/index.d.ts.map +1 -0
  83. package/dist/release/index.js +1365 -0
  84. package/dist/release/index.js.map +35 -0
  85. package/dist/release/providers.d.ts +19 -0
  86. package/dist/release/providers.d.ts.map +1 -0
  87. package/dist/release/refused.d.ts +39 -0
  88. package/dist/release/refused.d.ts.map +1 -0
  89. package/dist/release/tar.d.ts +14 -0
  90. package/dist/release/tar.d.ts.map +1 -0
  91. package/dist/release/victoria.d.ts +36 -0
  92. package/dist/release/victoria.d.ts.map +1 -0
  93. package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
  94. package/docs/release-binary-catalogs.md +113 -0
  95. package/docs/release-binary-measured.md +163 -0
  96. package/docs/release-binary-refusals.md +57 -0
  97. package/docs/release-binary-upstream.md +163 -0
  98. package/docs/release-binary.md +154 -0
  99. package/package.json +5 -1
  100. package/src/caddy/access-forward-auth.test.ts +63 -0
  101. package/src/caddy/access-forward-auth.ts +120 -0
  102. package/src/caddy/index.ts +10 -0
  103. package/src/caddy/providers.test.ts +4 -0
  104. package/src/cloudflare/mcp-server-api.ts +294 -0
  105. package/src/cloudflare/mcp-server-form.ts +219 -0
  106. package/src/index.ts +1 -0
  107. package/src/launchd/file-converge.ts +233 -0
  108. package/src/launchd/file-identity.ts +69 -0
  109. package/src/launchd/host-file-alias.test.ts +65 -0
  110. package/src/launchd/host-file-form.ts +2 -1
  111. package/src/launchd/host-file-lifecycle.ts +25 -144
  112. package/src/launchd/host-file.ts +8 -2
  113. package/src/launchd/local-runner.test.ts +12 -1
  114. package/src/launchd/local-runner.ts +2 -0
  115. package/src/launchd/runner.ts +17 -2
  116. package/src/launchd/sudo-runner.ts +11 -0
  117. package/src/linux/sudo-listing.ts +122 -0
  118. package/src/proxmox/credential-literals.ts +1 -1
  119. package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
  120. package/src/proxmox/generated/pbs.ts +7 -0
  121. package/src/proxmox/generated/pve.ts +7 -0
  122. package/src/proxmox/index.ts +1 -1
  123. package/src/proxmox/pbs-notification-target-form.ts +1 -1
  124. package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
  125. package/src/proxmox/pbs-notification-target-wire.ts +1 -1
  126. package/src/proxmox/pbs-notification-target.ts +1 -1
  127. package/src/release/adopt-parity.test.ts +106 -0
  128. package/src/release/apply-pins.test.ts +128 -0
  129. package/src/release/archive.ts +94 -0
  130. package/src/release/binary-alias.test.ts +109 -0
  131. package/src/release/binary-claim.ts +80 -0
  132. package/src/release/binary-claims.test.ts +47 -0
  133. package/src/release/binary-diff.ts +73 -0
  134. package/src/release/binary-form.ts +229 -0
  135. package/src/release/binary-lifecycle.test.ts +90 -0
  136. package/src/release/binary-lifecycle.ts +202 -0
  137. package/src/release/binary-read.ts +55 -0
  138. package/src/release/binary-refusals.test.ts +192 -0
  139. package/src/release/binary-sudo.test.ts +93 -0
  140. package/src/release/binary.ts +181 -0
  141. package/src/release/catalog.test.ts +96 -0
  142. package/src/release/catalog.ts +175 -0
  143. package/src/release/checksums.test.ts +35 -0
  144. package/src/release/checksums.ts +43 -0
  145. package/src/release/declared-pins.ts +39 -0
  146. package/src/release/download.test.ts +138 -0
  147. package/src/release/download.ts +110 -0
  148. package/src/release/error-channel.test.ts +69 -0
  149. package/src/release/fake-deploy.ts +87 -0
  150. package/src/release/fake-engine.ts +50 -0
  151. package/src/release/fake-install.ts +59 -0
  152. package/src/release/fake-provider.ts +68 -0
  153. package/src/release/fake-release.ts +185 -0
  154. package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
  155. package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
  156. package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
  157. package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
  158. package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
  159. package/src/release/index.ts +41 -0
  160. package/src/release/plan.test.ts +189 -0
  161. package/src/release/provider-refusals.test.ts +48 -0
  162. package/src/release/providers.test.ts +174 -0
  163. package/src/release/providers.ts +24 -0
  164. package/src/release/recovery-read.test.ts +44 -0
  165. package/src/release/refused.ts +40 -0
  166. package/src/release/tar.test.ts +142 -0
  167. package/src/release/tar.ts +195 -0
  168. package/src/release/victoria.test.ts +108 -0
  169. package/src/release/victoria.ts +154 -0
  170. package/src/{proxmox → secrets}/write-only.ts +6 -0
  171. /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
  172. /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
@@ -0,0 +1,294 @@
1
+ /**
2
+ * The Access **AI Controls MCP server** calls a portal entry needs, and nothing else.
3
+ *
4
+ * ★ A PORTAL ENTRY IS ITS OWN OBJECT, NOT AN ACCESS APPLICATION. Cloudflare's MCP server portals
5
+ * model one upstream server as THREE objects: this one
6
+ * (`/accounts/{account_id}/access/ai-controls/mcp/servers`), the portal that aggregates it, and
7
+ * an Access application of type `mcp` whose only destination is
8
+ * `{ type: "via_mcp_server_portal", mcp_server_id: <this id> }`, which carries the policies that
9
+ * decide who SEES the server in the portal (developers.cloudflare.com/cloudflare-one/
10
+ * access-controls/ai-controls/secure-mcp-servers/ and .../mcp-portals/, read 2026-09-22).
11
+ * Alchemy beta.79 ships `Cloudflare.Access.McpPortal` for the second and says in its own doc
12
+ * comment that the first is "managed out of band (a future `Cloudflare.Access.McpServer`
13
+ * resource)". This is that resource; mcp-access-app.ts covers the third.
14
+ *
15
+ * ★ OVER `@distilled.cloud/cloudflare`, THE SDK ALCHEMY'S OWN CLOUDFLARE PROVIDERS USE. Measured
16
+ * 2026-09-22: the official `cloudflare` npm SDK 4.5.0 has no AI Controls resources at all — its
17
+ * `src/resources/zero-trust/` stops at access/devices/dex/dlp/gateway/identity-providers/
18
+ * networks/organizations/risk-scoring/tunnels — while distilled `1.0.0-rc.12` carries all six
19
+ * verbs used below. So there is no path string this file invents.
20
+ *
21
+ * ⛔ THE SERVER ID IS CLIENT-SUPPLIED AND REQUIRED ON CREATE. `CreateAccessAiControlMcpServer
22
+ * Request.id` is a request field, not a server-generated one, which is why the resource takes it
23
+ * as a required prop: the Access application that governs the entry names the SAME id in its
24
+ * destination, so a generated id would have to be read back before the app could be declared.
25
+ *
26
+ * ⛔ NO TYPED `NotFound` EXISTS FOR THESE OPERATIONS. Measured 2026-09-22: every MCP-server verb
27
+ * declares its error channel as the bare `CloudflareOpError`, whose `DefaultErrors` are
28
+ * Unauthorized / TooManyRequests / InternalServerError / BadGateway / ServiceUnavailable /
29
+ * GatewayTimeout — `NotFound` and `Forbidden` are NOT in the union, though distilled's protocol
30
+ * constructs both at runtime from `HTTP_STATUS_MAP` (protocol.js, the 4xx branch). So "gone" is
31
+ * matched on the runtime `_tag` through `Effect.catchIf`; `Effect.catchTag('NotFound', …)` does
32
+ * not typecheck here, and a reviewer who "fixes" it to `catchTag` breaks the build, not the
33
+ * behaviour.
34
+ */
35
+ import * as zeroTrust from '@distilled.cloud/cloudflare/zero-trust';
36
+ import * as Data from 'effect/Data';
37
+ import * as Effect from 'effect/Effect';
38
+ import * as Predicate from 'effect/Predicate';
39
+ import * as Stream from 'effect/Stream';
40
+
41
+ /** How the portal authenticates to the upstream MCP server. */
42
+ export type McpServerAuthType = 'oauth' | 'bearer' | 'unauthenticated';
43
+
44
+ /** Cloudflare's four sync states for a portal entry's tool/prompt catalog. */
45
+ export type McpServerStatus = 'waiting' | 'ready' | 'stale' | 'error';
46
+
47
+ /** Whether an administrator still has to authenticate before a catalog sync can succeed. */
48
+ export type McpServerAuthenticationStatus =
49
+ | 'not_required'
50
+ | 'required'
51
+ | 'connected'
52
+ | 'stale'
53
+ | 'manual';
54
+
55
+ /** A server-wide override for one tool or prompt the upstream server advertises. */
56
+ export interface McpCapabilityOverride {
57
+ /** Name of the upstream capability being overridden. */
58
+ readonly name: string;
59
+ /** Custom name exposed through the portal. */
60
+ readonly alias?: string;
61
+ /** Custom description exposed through the portal. */
62
+ readonly description?: string;
63
+ /** Whether the capability is available through the portal at all. */
64
+ readonly enabled?: boolean;
65
+ }
66
+
67
+ /** A live portal entry as this package sees it. ⛔ There is no credential field here, on purpose. */
68
+ export interface ObservedServer {
69
+ readonly id: string;
70
+ readonly name: string;
71
+ readonly hostname: string;
72
+ readonly authType: McpServerAuthType;
73
+ readonly description: string | undefined;
74
+ readonly isSharedOauthCallbackEnabled: boolean;
75
+ readonly secureWebGateway: boolean;
76
+ readonly status: McpServerStatus | undefined;
77
+ readonly authenticationStatus: McpServerAuthenticationStatus | undefined;
78
+ readonly lastSynced: string | undefined;
79
+ readonly createdAt: string | undefined;
80
+ }
81
+
82
+ /** A refusal or failure, carrying the sentence an operator needs. Never a credential. */
83
+ export class McpServerError extends Data.TaggedError('McpServerError')<{
84
+ readonly message: string;
85
+ }> {}
86
+
87
+ type Raw = {
88
+ readonly id?: string | null;
89
+ readonly name?: string | null;
90
+ readonly hostname?: string | null;
91
+ readonly authType?: string | null;
92
+ readonly description?: string | null;
93
+ readonly isSharedOauthCallbackEnabled?: boolean | null;
94
+ readonly secureWebGateway?: boolean | null;
95
+ readonly status?: string | null;
96
+ readonly authenticationStatus?: string | null;
97
+ readonly lastSynced?: string | null;
98
+ readonly createdAt?: string | null;
99
+ };
100
+
101
+ const AUTH_TYPES: readonly string[] = ['oauth', 'bearer', 'unauthenticated'];
102
+ const STATUSES: readonly string[] = ['waiting', 'ready', 'stale', 'error'];
103
+ const AUTHENTICATION_STATUSES: readonly string[] = [
104
+ 'not_required',
105
+ 'required',
106
+ 'connected',
107
+ 'stale',
108
+ 'manual',
109
+ ];
110
+
111
+ const oneOf = <T extends string>(allowed: readonly string[], value: unknown): T | undefined =>
112
+ typeof value === 'string' && allowed.includes(value) ? (value as T) : undefined;
113
+
114
+ /**
115
+ * ⚠️ AN UNRECOGNISED `auth_type` IS NOT A PORTAL ENTRY THIS RESOURCE UNDERSTANDS. The field is
116
+ * required on every documented response, so `undefined` means Cloudflare grew a fourth mode —
117
+ * dropping the row is safer than recording a value the diff would then compare against.
118
+ */
119
+ export const observe = (raw: Raw): ObservedServer | undefined => {
120
+ if (typeof raw.id !== 'string' || raw.id.length === 0) return undefined;
121
+ const authType = oneOf<McpServerAuthType>(AUTH_TYPES, raw.authType);
122
+ if (authType === undefined) return undefined;
123
+ return {
124
+ id: raw.id,
125
+ name: raw.name ?? '',
126
+ hostname: raw.hostname ?? '',
127
+ authType,
128
+ description: raw.description || undefined,
129
+ isSharedOauthCallbackEnabled: raw.isSharedOauthCallbackEnabled ?? false,
130
+ secureWebGateway: raw.secureWebGateway ?? false,
131
+ status: oneOf<McpServerStatus>(STATUSES, raw.status),
132
+ authenticationStatus: oneOf<McpServerAuthenticationStatus>(
133
+ AUTHENTICATION_STATUSES,
134
+ raw.authenticationStatus,
135
+ ),
136
+ lastSynced: raw.lastSynced ?? undefined,
137
+ createdAt: raw.createdAt ?? undefined,
138
+ };
139
+ };
140
+
141
+ const tagged = (tag: string) => (error: unknown) =>
142
+ Predicate.hasProperty(error, '_tag') && error['_tag'] === tag;
143
+
144
+ const isNotFound = tagged('NotFound');
145
+ const isForbidden = tagged('Forbidden');
146
+
147
+ /**
148
+ * ⛔ `Forbidden` HERE MEANS "NO AI CONTROLS ENTITLEMENT", NOT "WRONG TOKEN". Alchemy's own
149
+ * `McpPortal` treats a 403 on its LIST as "this account has no portals"; on a read or a write
150
+ * that would hide the one fact an operator needs, so it is turned into a sentence instead.
151
+ */
152
+ const entitlement = (what: string) =>
153
+ new McpServerError({
154
+ message:
155
+ `Cloudflare refused ${what} with 403. On the AI Controls MCP endpoints that means the ` +
156
+ 'account does not have the entitlement (the product is in open beta and must be enabled ' +
157
+ 'for the account), not that the API token is wrong — a wrong token answers 401.',
158
+ });
159
+
160
+ const withEntitlement = <A, E, R>(what: string, effect: Effect.Effect<A, E, R>) =>
161
+ effect.pipe(Effect.catchIf(isForbidden, () => Effect.fail(entitlement(what))));
162
+
163
+ /** The portal entry with this id, or `undefined` when it does not exist. */
164
+ export const getServer = (
165
+ accountId: string,
166
+ serverId: string,
167
+ ): Effect.Effect<ObservedServer | undefined, unknown, zeroTrust.CloudflareOpContext> =>
168
+ withEntitlement(
169
+ `reading MCP server "${serverId}"`,
170
+ zeroTrust.readAccessAiControlMcpServer({ accountId, id: serverId }).pipe(Effect.map(observe)),
171
+ ).pipe(Effect.catchIf(isNotFound, () => Effect.succeed(undefined)));
172
+
173
+ /** Every portal entry in the account. ⚠️ An account without the entitlement has none. */
174
+ export const listServers = (
175
+ accountId: string,
176
+ ): Effect.Effect<readonly ObservedServer[], unknown, zeroTrust.CloudflareOpContext> =>
177
+ zeroTrust.listAccessAiControlMcpServers.items({ accountId }).pipe(
178
+ Stream.map(observe),
179
+ Stream.filter((server): server is ObservedServer => server !== undefined),
180
+ Stream.runCollect,
181
+ Effect.map((chunk) => Array.from(chunk)),
182
+ Effect.catchIf(isForbidden, () => Effect.succeed([] as readonly ObservedServer[])),
183
+ );
184
+
185
+ /** The fields a create carries beyond the identity. `undefined` keys are omitted, never sent. */
186
+ export interface ServerWrite {
187
+ readonly name: string;
188
+ readonly description?: string;
189
+ readonly isSharedOauthCallbackEnabled?: boolean;
190
+ readonly secureWebGateway?: boolean;
191
+ readonly updatedTools?: readonly McpCapabilityOverride[];
192
+ readonly updatedPrompts?: readonly McpCapabilityOverride[];
193
+ /** Resolved at call time from the deploying process's environment. Never stored. */
194
+ readonly authCredentials?: string;
195
+ /** Resolved at call time from the deploying process's environment. Never stored. */
196
+ readonly clientSecret?: string;
197
+ }
198
+
199
+ const optional = (write: ServerWrite) => ({
200
+ ...(write.description === undefined ? {} : { description: write.description }),
201
+ ...(write.isSharedOauthCallbackEnabled === undefined
202
+ ? {}
203
+ : { isSharedOauthCallbackEnabled: write.isSharedOauthCallbackEnabled }),
204
+ ...(write.secureWebGateway === undefined ? {} : { secureWebGateway: write.secureWebGateway }),
205
+ ...(write.updatedTools === undefined ? {} : { updatedTools: [...write.updatedTools] }),
206
+ ...(write.updatedPrompts === undefined ? {} : { updatedPrompts: [...write.updatedPrompts] }),
207
+ ...(write.authCredentials === undefined ? {} : { authCredentials: write.authCredentials }),
208
+ ...(write.clientSecret === undefined ? {} : { clientSecret: write.clientSecret }),
209
+ });
210
+
211
+ /**
212
+ * ⛔ `hostname` AND `authType` ARE SENT ON CREATE AND NOWHERE ELSE. Measured 2026-09-22 against
213
+ * the SDK: `UpdateAccessAiControlMcpServerRequest` carries `authCredentials`, `clientSecret`,
214
+ * `description`, `isSharedOauthCallbackEnabled`, `name`, `secureWebGateway`, `updatedPrompts`
215
+ * and `updatedTools` — and NEITHER `hostname` NOR `authType`. So changing either is a replace,
216
+ * not an update, and mcp-server-form.ts says so in `diff`.
217
+ */
218
+ export const createServer = (
219
+ accountId: string,
220
+ serverId: string,
221
+ hostname: string,
222
+ authType: McpServerAuthType,
223
+ write: ServerWrite,
224
+ ): Effect.Effect<ObservedServer, unknown, zeroTrust.CloudflareOpContext> =>
225
+ withEntitlement(
226
+ `creating MCP server "${serverId}"`,
227
+ zeroTrust
228
+ .createAccessAiControlMcpServer({
229
+ accountId,
230
+ id: serverId,
231
+ hostname,
232
+ authType,
233
+ name: write.name,
234
+ ...optional(write),
235
+ })
236
+ .pipe(
237
+ Effect.flatMap((raw) => {
238
+ const server = observe(raw);
239
+ return server === undefined
240
+ ? Effect.fail(
241
+ new McpServerError({
242
+ message: `Creating MCP server "${serverId}" returned no id or an unknown auth_type.`,
243
+ }),
244
+ )
245
+ : Effect.succeed(server);
246
+ }),
247
+ ),
248
+ );
249
+
250
+ /** An in-place update of the mutable fields. The identity, hostname and auth type never move. */
251
+ export const updateServer = (
252
+ accountId: string,
253
+ serverId: string,
254
+ write: ServerWrite,
255
+ ): Effect.Effect<ObservedServer | undefined, unknown, zeroTrust.CloudflareOpContext> =>
256
+ withEntitlement(
257
+ `updating MCP server "${serverId}"`,
258
+ zeroTrust
259
+ .updateAccessAiControlMcpServer({
260
+ accountId,
261
+ id: serverId,
262
+ name: write.name,
263
+ ...optional(write),
264
+ })
265
+ .pipe(Effect.map(observe)),
266
+ );
267
+
268
+ /**
269
+ * Refresh the entry's tool and prompt catalog from the upstream server.
270
+ *
271
+ * ⛔ BEST EFFORT, AND THE DEPLOY NEVER FAILS ON IT. An `oauth` entry reports
272
+ * `authentication_status: "required"` until an administrator completes the upstream OAuth flow
273
+ * in the dashboard, and a sync before that cannot succeed. Failing the create over it would make
274
+ * the FIRST deploy of every Access-protected MCP server red while the object it created is
275
+ * correct. The observed `status` is recorded instead, so a plan shows `waiting` or `error`.
276
+ */
277
+ export const syncServer = (
278
+ accountId: string,
279
+ serverId: string,
280
+ ): Effect.Effect<McpServerStatus | undefined, never, zeroTrust.CloudflareOpContext> =>
281
+ zeroTrust.syncAccessAiControlMcpServer({ accountId, id: serverId }).pipe(
282
+ Effect.map((raw) => oneOf<McpServerStatus>(STATUSES, raw.status)),
283
+ Effect.catchCause(() => Effect.succeed(undefined)),
284
+ );
285
+
286
+ /** Idempotent: an entry already gone is a successful delete. */
287
+ export const deleteServer = (
288
+ accountId: string,
289
+ serverId: string,
290
+ ): Effect.Effect<void, unknown, zeroTrust.CloudflareOpContext> =>
291
+ withEntitlement(
292
+ `deleting MCP server "${serverId}"`,
293
+ zeroTrust.deleteAccessAiControlMcpServer({ accountId, id: serverId }).pipe(Effect.asVoid),
294
+ ).pipe(Effect.catchIf(isNotFound, () => Effect.void));
@@ -0,0 +1,219 @@
1
+ /**
2
+ * `Cloudflare.McpServer` — its props, its attributes, and the pure `diff` that decides between
3
+ * update and replace.
4
+ *
5
+ * ⛔ THE ATTRIBUTES NEVER HOLD A CREDENTIAL. `auth_credentials` and `client_secret` are write-only
6
+ * at the API (the read endpoints return an `auth_config_summary` that never includes the secret
7
+ * value), and Alchemy's state store is not encrypted — `StateEncoding.ts` TAGS a `Redacted`
8
+ * value and writes the inner value beside the tag, so `Redacted` hides a secret from a log line
9
+ * and from nothing else. So both are declared as `{ fromEnv: 'NAME' }` (secrets/write-only.ts),
10
+ * the NAME lands in state, and the value exists only in the deploying process and on the wire.
11
+ * ★ AND A PLAN STILL NOTICES A CHANGE, because what was last written is remembered as a salted
12
+ * scrypt SEAL, the same mechanism the PBS notification targets use.
13
+ */
14
+ import type { Diff } from 'alchemy/Diff';
15
+ import type { Input } from 'alchemy/Input';
16
+ import type { FromEnv } from '../secrets/write-only.ts';
17
+ import {
18
+ type McpCapabilityOverride,
19
+ type McpServerAuthType,
20
+ type McpServerAuthenticationStatus,
21
+ McpServerError,
22
+ type McpServerStatus,
23
+ } from './mcp-server-api.ts';
24
+
25
+ export interface McpServerProps {
26
+ /**
27
+ * The entry's id in the account — client-supplied, required, and the identity everything else
28
+ * names. ⛔ Changing it REPLACES the entry, because the id is what the governing Access
29
+ * application's `{ type: 'via_mcp_server_portal', mcpServerId }` destination points at.
30
+ * ★ Required rather than generated: a generated id would have to be read back out of state
31
+ * before that application could be declared, which is the opposite of a declaration.
32
+ */
33
+ readonly serverId: string;
34
+ /** Display name in the portal. Defaults to the id. */
35
+ readonly name?: string;
36
+ /**
37
+ * The FULL upstream MCP endpoint URL, including its path — `https://example.com/mcp`, not
38
+ * `example.com`. ⚠️ THE FIELD IS NAMED `hostname` AND IS NOT ONE; Cloudflare's own API doc
39
+ * calls it "URL of the upstream MCP endpoint". A bare host is refused by `validateMcpServer`
40
+ * rather than sent, because the portal would then fetch `https:///` and report a sync error
41
+ * that reads like the upstream server being down.
42
+ * ⛔ CREATE-ONLY: the update request has no `hostname`, so a change is a replace.
43
+ * ⚠️ The upstream hostname must be PROXIED in Cloudflare DNS for a portal to reach it
44
+ * (developers.cloudflare.com/cloudflare-one/access-controls/ai-controls/mcp-portals/).
45
+ */
46
+ readonly hostname: string;
47
+ /**
48
+ * How the portal authenticates to the upstream server.
49
+ * ⛔ CREATE-ONLY, like `hostname`, and required for that reason — a default would silently fix
50
+ * a choice that can only be changed by replacing the entry.
51
+ * - `oauth` — the upstream server is its own OAuth resource (including one fronted by
52
+ * Cloudflare Access managed OAuth). The portal registers itself; no static secret here.
53
+ * - `bearer` — a static credential, declared as `authCredentials`.
54
+ * - `unauthenticated` — no credential at all.
55
+ */
56
+ readonly authType: McpServerAuthType;
57
+ /**
58
+ * The static credential for `authType: 'bearer'`, by the NAME of the environment variable
59
+ * holding it. Either a raw token, or a JSON object `{"headers":{…}}` for custom headers — which
60
+ * is how an Access service token pair (`cf-access-client-id` / `cf-access-client-secret`) is
61
+ * sent, per Cloudflare's own example.
62
+ */
63
+ readonly authCredentials?: FromEnv;
64
+ /** The pre-registered OAuth client secret for a manual-mode `oauth` entry, by variable name. */
65
+ readonly clientSecret?: FromEnv;
66
+ readonly description?: string;
67
+ /**
68
+ * Use Cloudflare's shared OAuth callback instead of the portal hostname's. Server default off.
69
+ */
70
+ readonly isSharedOauthCallbackEnabled?: boolean;
71
+ /** Route the portal's outbound traffic to this server through the Secure Web Gateway. */
72
+ readonly secureWebGateway?: boolean;
73
+ /** Server-wide tool overrides — rename, re-describe or disable an upstream tool. */
74
+ readonly updatedTools?: readonly McpCapabilityOverride[];
75
+ /** Server-wide prompt overrides. */
76
+ readonly updatedPrompts?: readonly McpCapabilityOverride[];
77
+ /**
78
+ * Re-run the catalog sync on every reconcile, not only after the create.
79
+ * ⚠️ A sync is a WRITE that talks to the upstream server; leaving this off keeps a no-op deploy
80
+ * from waking it. Turn it on for a server whose tool list changes with its own releases.
81
+ */
82
+ readonly resync?: boolean;
83
+ }
84
+
85
+ export interface McpServerAttributes {
86
+ readonly serverId: string;
87
+ /** The account the provider environment resolved when the entry was written. */
88
+ readonly accountId: string;
89
+ readonly name: string;
90
+ readonly hostname: string;
91
+ readonly authType: McpServerAuthType;
92
+ readonly description: string | undefined;
93
+ readonly isSharedOauthCallbackEnabled: boolean;
94
+ readonly secureWebGateway: boolean;
95
+ /** Catalog sync state as of the last read or write. It moves on its own. */
96
+ readonly status: McpServerStatus | undefined;
97
+ /** `required` means an administrator must still finish the upstream OAuth flow by hand. */
98
+ readonly authenticationStatus: McpServerAuthenticationStatus | undefined;
99
+ readonly lastSynced: string | undefined;
100
+ readonly createdAt: string | undefined;
101
+ /** `scrypt:<salt>:<digest>` of the credential last written, or `''`. ⛔ Never the value. */
102
+ readonly authCredentialsSeal: string;
103
+ /** `scrypt:<salt>:<digest>` of the client secret last written, or `''`. ⛔ Never the value. */
104
+ readonly clientSecretSeal: string;
105
+ }
106
+
107
+ const ID_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
108
+
109
+ const idProblem = (serverId: unknown): string | undefined => {
110
+ if (typeof serverId !== 'string' || serverId.length === 0) return 'must be a non-empty string';
111
+ if (serverId !== serverId.trim()) return 'has leading or trailing whitespace';
112
+ if (!ID_SHAPE.test(serverId)) {
113
+ return 'must be 1-64 characters of letters, digits, dot, dash or underscore, starting with a letter or digit';
114
+ }
115
+ return undefined;
116
+ };
117
+
118
+ /**
119
+ * ⛔ THE ONE REFUSAL THAT PAYS FOR ITSELF: a bare host in `hostname`. The field's NAME invites it,
120
+ * the API accepts it, and the portal then has no endpoint to call.
121
+ */
122
+ const hostnameProblem = (hostname: unknown): string | undefined => {
123
+ if (typeof hostname !== 'string' || hostname.length === 0) return 'must be a non-empty string';
124
+ let url: URL;
125
+ try {
126
+ url = new URL(hostname);
127
+ } catch {
128
+ return `must be a full URL including the scheme and path, e.g. "https://${hostname}/mcp" — the field is named \`hostname\` but Cloudflare documents it as "URL of the upstream MCP endpoint"`;
129
+ }
130
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
131
+ return `must be an http(s) URL; "${url.protocol}" is not one the portal can fetch`;
132
+ }
133
+ if (url.hash !== '') return 'must not carry a fragment; the portal sends the URL as given';
134
+ return undefined;
135
+ };
136
+
137
+ /** Which credential props each auth type may carry. ⛔ Fail closed on a combination nobody meant. */
138
+ const credentialProblem = (props: McpServerProps): string | undefined => {
139
+ const hasCredentials = props.authCredentials !== undefined;
140
+ const hasSecret = props.clientSecret !== undefined;
141
+ if (props.authType === 'bearer') {
142
+ if (!hasCredentials) return '`authType: "bearer"` needs `authCredentials: { fromEnv: … }`';
143
+ if (hasSecret) return '`clientSecret` belongs to a manual-mode `oauth` entry, not to `bearer`';
144
+ return undefined;
145
+ }
146
+ if (hasCredentials) {
147
+ return `\`authCredentials\` is the static credential for \`authType: "bearer"\`; this entry is "${props.authType}"`;
148
+ }
149
+ if (props.authType === 'unauthenticated' && hasSecret) {
150
+ return '`clientSecret` belongs to a manual-mode `oauth` entry, not to `unauthenticated`';
151
+ }
152
+ return undefined;
153
+ };
154
+
155
+ /** ⛔ Fail closed on a declaration the API would accept but nobody meant. */
156
+ export const validateMcpServer = (props: McpServerProps): McpServerError | undefined => {
157
+ const id = idProblem(props.serverId);
158
+ if (id !== undefined) return new McpServerError({ message: `McpServer \`serverId\` ${id}.` });
159
+ const host = hostnameProblem(props.hostname);
160
+ if (host !== undefined) {
161
+ return new McpServerError({
162
+ message: `McpServer "${props.serverId}" \`hostname\` ${host}.`,
163
+ });
164
+ }
165
+ if (
166
+ props.authType !== 'oauth' &&
167
+ props.authType !== 'bearer' &&
168
+ props.authType !== 'unauthenticated'
169
+ ) {
170
+ return new McpServerError({
171
+ message: `McpServer "${props.serverId}" \`authType\` must be "oauth", "bearer" or "unauthenticated". It is create-only, so it has no default.`,
172
+ });
173
+ }
174
+ const credential = credentialProblem(props);
175
+ return credential === undefined
176
+ ? undefined
177
+ : new McpServerError({ message: `McpServer "${props.serverId}": ${credential}.` });
178
+ };
179
+
180
+ /**
181
+ * update or replace, from the declaration and what was last written.
182
+ *
183
+ * - Account changed → `replace`, create-first: ids are unique per ACCOUNT, so nothing collides.
184
+ * - `serverId` changed → `replace`, create-first: the new entry's id is free by definition.
185
+ * - `hostname` or `authType` changed → `replace`, and ⛔ `deleteFirst` when the id stays the
186
+ * same, because the new generation must create the SAME id the old one still holds. Create-first
187
+ * there would answer "already exists" — or, worse, a create path that "converged" on the live
188
+ * entry would record a hostname it does not have.
189
+ * - Otherwise → no opinion; the engine compares props.
190
+ *
191
+ * ⚠️ A deleteFirst replace means the entry is briefly absent from the portal, and the Access
192
+ * application that names `mcpServerId` keeps pointing at an id that exists again a moment later.
193
+ * That is the only order the API allows for a create-only field on a client-supplied id.
194
+ */
195
+ export const diffMcpServer = (
196
+ news: Input<McpServerProps>,
197
+ output: McpServerAttributes | undefined,
198
+ accountId: string,
199
+ ): Diff | undefined => {
200
+ if (output === undefined) return undefined;
201
+ if (output.accountId !== accountId) return { action: 'replace' };
202
+ const fields = news as {
203
+ readonly serverId?: unknown;
204
+ readonly hostname?: unknown;
205
+ readonly authType?: unknown;
206
+ };
207
+ const serverId = typeof fields.serverId === 'string' ? fields.serverId : undefined;
208
+ if (serverId !== undefined && serverId !== output.serverId) return { action: 'replace' };
209
+ const hostname = typeof fields.hostname === 'string' ? fields.hostname : undefined;
210
+ const authType = typeof fields.authType === 'string' ? fields.authType : undefined;
211
+ const createOnlyMoved =
212
+ (hostname !== undefined && hostname !== output.hostname) ||
213
+ (authType !== undefined && authType !== output.authType);
214
+ if (!createOnlyMoved) return undefined;
215
+ // ★ An unresolved `serverId` counts as unchanged, which picks the order that cannot collide.
216
+ return serverId === undefined || serverId === output.serverId
217
+ ? { action: 'replace', deleteFirst: true }
218
+ : { action: 'replace' };
219
+ };
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  * import { TalosCluster } from '@homeflare/alchemy/talos';
12
12
  * import { ProxmoxAcl } from '@homeflare/alchemy/proxmox';
13
13
  * import { LaunchdJob } from '@homeflare/alchemy/launchd';
14
+ * import { ReleaseBinary } from '@homeflare/alchemy/release';
14
15
  * import { CaddyConfig } from '@homeflare/alchemy/caddy';
15
16
  *
16
17
  * ★ WHY THESE EXIST AT ALL. When Alchemy has no resource for something, the alternative