@frockbot/cloudflare 0.0.0 → 0.7.292

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 (88) hide show
  1. package/build-artifact.ts +130 -0
  2. package/build-flutter-web.ts +508 -0
  3. package/deployment-config/README.md +414 -0
  4. package/deployment-config/cli.ts +228 -0
  5. package/deployment-config/generate.ts +689 -0
  6. package/deployment-config/jsonc.ts +18 -0
  7. package/deployment-config/profile-schema.generated.ts +391 -0
  8. package/deployment-config/profile.schema.json +358 -0
  9. package/deployment-config/profile.ts +124 -0
  10. package/migrations/0001_better_auth.sql +15 -0
  11. package/migrations/0002_drop_account_issuer.sql +8 -0
  12. package/package.json +61 -5
  13. package/release-version.ts +20 -0
  14. package/src/account-admission.ts +179 -0
  15. package/src/account-deletion.ts +274 -0
  16. package/src/admin-entrypoint.ts +194 -0
  17. package/src/admin-identities.ts +26 -0
  18. package/src/audit.ts +103 -0
  19. package/src/auth-package.access.ts +24 -0
  20. package/src/auth-package.ts +36 -0
  21. package/src/avatar-state-cleanup.ts +107 -0
  22. package/src/billing-computer.ts +267 -0
  23. package/src/billing-readiness.ts +21 -0
  24. package/src/billing.ts +461 -0
  25. package/src/bot-capabilities.ts +480 -0
  26. package/src/bot-recovery.ts +1 -0
  27. package/src/bot-state-channel.ts +702 -0
  28. package/src/bot-state.ts +3995 -0
  29. package/src/bot-template-cleanup.ts +98 -0
  30. package/src/bot-title-cleanup.ts +104 -0
  31. package/src/brand-icon.png +0 -0
  32. package/src/brand-logo.ts +2 -0
  33. package/src/brand.ts +36 -0
  34. package/src/client-compatibility.ts +51 -0
  35. package/src/compaction-announcement-cleanup.ts +113 -0
  36. package/src/computer-egress.ts +110 -0
  37. package/src/computer-host.ts +92 -0
  38. package/src/computer-screenshot-cleanup.ts +78 -0
  39. package/src/contracts.ts +832 -0
  40. package/src/debug.ts +272 -0
  41. package/src/default-packages-marker-cleanup.ts +39 -0
  42. package/src/deployment-policy-admin-host.ts +121 -0
  43. package/src/deployment-policy.ts +426 -0
  44. package/src/directory-profile-cleanup.ts +101 -0
  45. package/src/durable-rpc.ts +753 -0
  46. package/src/durable-session.ts +21 -0
  47. package/src/entry-boundary.ts +103 -0
  48. package/src/frock-ai.ts +338 -0
  49. package/src/gateway.ts +1392 -0
  50. package/src/group-chat.ts +736 -0
  51. package/src/hidden-bot-notifications-cleanup.ts +32 -0
  52. package/src/index.ts +2798 -0
  53. package/src/insights.ts +15 -0
  54. package/src/machine-messages-cleanup.ts +121 -0
  55. package/src/machine-socket.ts +164 -0
  56. package/src/memory-records.ts +181 -0
  57. package/src/memory.ts +145 -0
  58. package/src/model-rates.ts +252 -0
  59. package/src/native-auth.ts +1006 -0
  60. package/src/native-sessions.ts +121 -0
  61. package/src/notification-state-cleanup.ts +18 -0
  62. package/src/ollama-web-search-cleanup.ts +69 -0
  63. package/src/package-page-shapes-cleanup.ts +190 -0
  64. package/src/plugin-egress.ts +31 -0
  65. package/src/plugin-page-route.ts +72 -0
  66. package/src/plugin-panels-cleanup.ts +101 -0
  67. package/src/prepared-input-cleanup.ts +105 -0
  68. package/src/production-secrets.ts +484 -0
  69. package/src/project-cleanup.ts +123 -0
  70. package/src/project-events-cleanup.ts +152 -0
  71. package/src/publication-state-cleanup.ts +92 -0
  72. package/src/push.ts +498 -0
  73. package/src/request-body.ts +165 -0
  74. package/src/routine-state-cleanup.ts +57 -0
  75. package/src/search.ts +88 -0
  76. package/src/sidebar-label-cleanup.ts +165 -0
  77. package/src/skill-index-cleanup.ts +53 -0
  78. package/src/supersede-cleanup.ts +162 -0
  79. package/src/test-chat-cleanup.ts +93 -0
  80. package/src/uploads.ts +486 -0
  81. package/src/user-application.ts +1068 -0
  82. package/src/user-configuration.ts +4491 -0
  83. package/src/voice-assistant.ts +4999 -0
  84. package/src/voice-dictation.ts +743 -0
  85. package/src/working-context-cleanup.ts +96 -0
  86. package/src/workspace.ts +201 -0
  87. package/wrangler.jsonc +385 -0
  88. package/README.md +0 -3
@@ -0,0 +1,105 @@
1
+ // Disposable cleanup for Turns admitted before they carried prepared inputs.
2
+ //
3
+ // Those runs cannot be executed: recovery must not substitute the live
4
+ // account. Non-terminal ones are removed, and pointers that named them are
5
+ // cleared. Finished runs stay, so a transcript that only needed their record
6
+ // can still be read. The walk is once per Bot; it pages rather than loading
7
+ // the archive into one value.
8
+
9
+ import {
10
+ ACTIVE_RUN_KEY,
11
+ PENDING_AGENT_RUN_PREFIX,
12
+ PENDING_USER_RUN_PREFIX,
13
+ RUN_INDEX_PREFIX,
14
+ RUN_PREFIX,
15
+ } from "@frockbot/core/durable";
16
+
17
+ const RECEIPT = "maintenance:prepared-inputs:2026-09-22";
18
+ const PAGE = 50;
19
+
20
+ interface CleanupStorage {
21
+ get(key: string): Promise<unknown>;
22
+ put(key: string, value: unknown): Promise<void>;
23
+ delete(key: string): Promise<boolean>;
24
+ list(options: {
25
+ prefix?: string;
26
+ limit?: number;
27
+ start?: string;
28
+ }): Promise<Map<string, unknown>>;
29
+ }
30
+
31
+ function record(value: unknown): Record<string, unknown> | undefined {
32
+ if (!value || typeof value !== "object" || Array.isArray(value))
33
+ return undefined;
34
+ return value as Record<string, unknown>;
35
+ }
36
+
37
+ function nonTerminalRunId(value: unknown): string | undefined {
38
+ const stored = record(value);
39
+ if (!stored || stored.preparedInputs !== undefined) return undefined;
40
+ if (
41
+ stored.status === "completed" ||
42
+ stored.status === "failed" ||
43
+ stored.status === "cancelled"
44
+ ) {
45
+ return undefined;
46
+ }
47
+ return typeof stored.runId === "string" ? stored.runId : undefined;
48
+ }
49
+
50
+ async function page(
51
+ storage: CleanupStorage,
52
+ prefix: string,
53
+ visit: (key: string, value: unknown) => Promise<void>,
54
+ ): Promise<void> {
55
+ let start: string | undefined;
56
+ for (;;) {
57
+ const listed = await storage.list({
58
+ prefix,
59
+ limit: PAGE,
60
+ ...(start ? { start } : {}),
61
+ });
62
+ if (listed.size === 0) return;
63
+ let last = "";
64
+ for (const [key, value] of listed) {
65
+ last = key;
66
+ if (start !== undefined && key === start) continue;
67
+ await visit(key, value);
68
+ }
69
+ if (listed.size < PAGE) return;
70
+ start = `${last}\0`;
71
+ }
72
+ }
73
+
74
+ export async function cleanUnpreparedRunsV1(
75
+ storage: CleanupStorage,
76
+ ): Promise<void> {
77
+ if (await storage.get(RECEIPT)) return;
78
+ const removed = new Set<string>();
79
+ await page(storage, RUN_PREFIX, async (key, value) => {
80
+ const runId = nonTerminalRunId(value);
81
+ if (!runId) return;
82
+ removed.add(runId);
83
+ await storage.delete(key);
84
+ });
85
+ const active = await storage.get(ACTIVE_RUN_KEY);
86
+ if (typeof active === "string" && removed.has(active)) {
87
+ await storage.delete(ACTIVE_RUN_KEY);
88
+ }
89
+ for (const prefix of [PENDING_USER_RUN_PREFIX, PENDING_AGENT_RUN_PREFIX]) {
90
+ await page(storage, prefix, async (key, value) => {
91
+ if (typeof value === "string" && removed.has(value)) {
92
+ await storage.delete(key);
93
+ }
94
+ });
95
+ }
96
+ await page(storage, RUN_INDEX_PREFIX, async (key, value) => {
97
+ if (typeof value === "string" && removed.has(value)) {
98
+ await storage.delete(key);
99
+ }
100
+ });
101
+ await storage.put(RECEIPT, {
102
+ schemaVersion: 1,
103
+ removed: removed.size,
104
+ });
105
+ }
@@ -0,0 +1,484 @@
1
+ /*
2
+ * What the production Worker must be given, and what happens when it is not.
3
+ *
4
+ * Every string setting the Worker reads off `env` is named here exactly once,
5
+ * in one of three lists. That is the whole point of the module: a Worker that
6
+ * reads a new secret cannot ship until somebody has said, in this file,
7
+ * whether production is allowed to run without it — the release workflow
8
+ * builds its secrets file from these lists and refuses to deploy when a
9
+ * required name is missing, and `production-secrets.test.ts` fails when the
10
+ * `Env` interface names a setting these lists do not.
11
+ *
12
+ * A second, quieter hazard runs the other way. `wrangler deploy
13
+ * --secrets-file` is *additive*: it adds and overwrites the names the file
14
+ * carries and leaves every other secret the Worker already holds exactly
15
+ * where it is (`wrangler deploy --help`, 4.93). So no release ever revokes
16
+ * anything. Deleting a name from the production environment stops the
17
+ * release *updating* that secret; the value production is already running on
18
+ * stays live and stays authorized until somebody deletes it deliberately,
19
+ * with `bun scripts/check-production-secrets.ts revoke <NAME>`.
20
+ *
21
+ * That is why the live comparison below reports what a deploy actually does
22
+ * — adds, updates, leaves in place — rather than what it would do if the
23
+ * flag replaced the set, and why a door that must never be open in
24
+ * production (`ALLOW_DEVELOPMENT_AUTH`) fails the gate when the live Worker
25
+ * holds it: no deploy is going to close it.
26
+ *
27
+ * One list here is not the same for every deployment: a required secret may
28
+ * belong to one auth Package (ADR 0028). Both built-in Packages' names are
29
+ * classified, because the Worker's `Env` declares both, and only the built
30
+ * Package's are required, checked and deployed. A white-label's own Package is
31
+ * not in this file at all (ADR 0038 §3): its profile names the secrets it
32
+ * requires, and every function below takes them as the `auth` it checks.
33
+ */
34
+ import {
35
+ isBuiltInAuthPackageIdV1,
36
+ type AuthPackageIdV1,
37
+ type AuthPackageSettingV1,
38
+ type BuiltInAuthPackageIdV1,
39
+ } from "@frockbot/core/contracts";
40
+ import { AUTH_PACKAGE_V1 } from "#auth-package";
41
+
42
+ /** One setting the deploy hands the Worker. */
43
+ export interface ProductionSecretV1 {
44
+ readonly name: string;
45
+ /** What it is for, in one line, for the operator reading a failed deploy. */
46
+ readonly why: string;
47
+ /**
48
+ * Required by the deployment but read by another Worker in it, not by the
49
+ * app Worker. The deploy still refuses without it — the deployment is not
50
+ * complete — but the app Worker declares no `env` string for it, and the
51
+ * manifest's "names nothing the Worker does not read" rule skips it.
52
+ */
53
+ readonly hostOnly?: boolean;
54
+ /**
55
+ * Required by one auth Package only. A build that chose the other Package
56
+ * neither reads it nor deploys it, so it is required nowhere until that
57
+ * build is the one being released.
58
+ */
59
+ readonly authPackage?: BuiltInAuthPackageIdV1;
60
+ }
61
+
62
+ /**
63
+ * The auth Package a deploy is checked for: its name, and the secrets it
64
+ * requires. The build's own chooser by default; for a white-label's Package,
65
+ * what its profile names, since this module cannot import a chooser it was
66
+ * not built with.
67
+ */
68
+ export interface ProductionAuthPackageV1 {
69
+ readonly id: AuthPackageIdV1;
70
+ readonly required: readonly AuthPackageSettingV1[];
71
+ }
72
+
73
+ /**
74
+ * Whether this build carries the secret at all.
75
+ *
76
+ * The one asymmetry in this module: every other name is required or optional
77
+ * for every deployment, and these are required for one and unknown to the
78
+ * other.
79
+ */
80
+ function belongsToBuildV1(
81
+ secret: ProductionSecretV1,
82
+ auth: ProductionAuthPackageV1,
83
+ ): boolean {
84
+ return secret.authPackage === undefined || secret.authPackage === auth.id;
85
+ }
86
+
87
+ /** One setting the deploy may omit, and what the product loses when it does. */
88
+ export interface OptionalProductionSecretV1 extends ProductionSecretV1 {
89
+ /** What stops working while it is unset, said plainly. */
90
+ readonly degraded: string;
91
+ }
92
+
93
+ /** One setting the deploy never carries as a secret. */
94
+ export interface NonSecretWorkerSettingV1 extends ProductionSecretV1 {
95
+ /**
96
+ * Set on a door that must never be open in production: what it would let
97
+ * through if the live Worker held it as a secret. The deploy is additive
98
+ * and cannot close it, so the gate fails and an operator revokes it.
99
+ */
100
+ readonly forbiddenLive?: string;
101
+ }
102
+
103
+ /**
104
+ * Required: production is broken without it, so the deploy fails rather than
105
+ * shipping a Worker that answers an error to a User instead of a feature.
106
+ */
107
+ export const REQUIRED_PRODUCTION_SECRETS_V1: readonly ProductionSecretV1[] = [
108
+ {
109
+ name: "JEV_API_KEY",
110
+ why: "Authorizes Turn supervision against Jev: every Turn is supervised, so without it no Bot runs a Turn. The routine-event rejector, group replies and dictation tidy review use it too.",
111
+ },
112
+ {
113
+ name: "FCM_SERVICE_ACCOUNT",
114
+ why: "Authorizes Firebase push delivery to registered Android devices.",
115
+ },
116
+ {
117
+ name: "BETTER_AUTH_URL",
118
+ why: "The deployment's own origin; every sign-in redirect is built from it.",
119
+ authPackage: "better-auth",
120
+ },
121
+ {
122
+ name: "BETTER_AUTH_SECRET",
123
+ why: "Signs every session cookie. Absent, nobody can sign in.",
124
+ authPackage: "better-auth",
125
+ },
126
+ {
127
+ name: "GOOGLE_CLIENT_ID",
128
+ why: "The only sign-in method the hosted deployment offers.",
129
+ authPackage: "better-auth",
130
+ },
131
+ {
132
+ name: "GOOGLE_CLIENT_SECRET",
133
+ why: "The only sign-in method the hosted deployment offers.",
134
+ authPackage: "better-auth",
135
+ },
136
+ {
137
+ name: "ACCESS_TEAM_DOMAIN",
138
+ why: "The Zero Trust team whose public keys sign every Access token, and whose logout ends a session.",
139
+ authPackage: "access",
140
+ },
141
+ {
142
+ name: "ACCESS_AUD",
143
+ why: "The Access application's audience tag. Absent, a token minted for another application would be accepted.",
144
+ authPackage: "access",
145
+ },
146
+ {
147
+ // The hosted build signs the native door with `BETTER_AUTH_SECRET` above,
148
+ // which it has always signed with and which nothing may rotate without
149
+ // revoking every signed-in phone and Mac. A build with no better-auth has
150
+ // no such key, so the installer mints this one.
151
+ name: "NATIVE_TOKEN_SECRET",
152
+ why: "Signs the native sign-in codes and bearer tokens. Absent, the phone and Mac cannot sign in.",
153
+ authPackage: "access",
154
+ },
155
+ {
156
+ name: "SPRITES_TOKEN",
157
+ why: "Authorizes the Computer host against its vendor. Absent, no Computer starts.",
158
+ // Read by `apps/computer-host`, never by this Worker: the app Worker asks
159
+ // whether it was handed a Computer host, not which credential that host
160
+ // needs. It is required here because the deployment is not complete
161
+ // without it, and the release forwards it to both Workers.
162
+ hostOnly: true,
163
+ },
164
+ {
165
+ name: "COMPUTER_HOST_TOKEN",
166
+ why: "Presented on every call to the Computer host Worker.",
167
+ },
168
+ {
169
+ name: "CREDENTIAL_KEYRING",
170
+ why: "Decrypts stored Connection credentials. Absent, every Connection fails.",
171
+ },
172
+ {
173
+ name: "ROUTINE_HOOK_SECRET",
174
+ why: "Signs Routine webhook keys. Absent, every Routine webhook is refused.",
175
+ },
176
+ {
177
+ name: "MACHINE_TOKEN_SECRET",
178
+ why: "Signs machine tokens and pairing codes. Absent, no machine can pair.",
179
+ },
180
+ {
181
+ name: "APPLET_BUILD_TOKEN",
182
+ why: "Presented on every call to the plugin build service. Absent, no Plugin can be checked or published.",
183
+ },
184
+ {
185
+ name: "OPENAI_API_KEY",
186
+ why: "Opens the composer's dictation session. Absent, dictation answers that voice is not set up.",
187
+ },
188
+ {
189
+ name: "GEMINI_API_KEY",
190
+ why: "Runs the voice session: one Gemini Live socket per call, ears, words and voice together. Absent, the voice session refuses to start.",
191
+ },
192
+ ];
193
+
194
+ /**
195
+ * Optional: the deploy proceeds and says what stays shut. Each still has to be
196
+ * carried by the workflow: the deploy is the only thing that updates a
197
+ * secret, so a name the workflow drops is a value frozen at whatever
198
+ * production last received.
199
+ */
200
+ export const OPTIONAL_PRODUCTION_SECRETS_V1: readonly OptionalProductionSecretV1[] =
201
+ [
202
+ {
203
+ name: "STRIPE_SECRET_KEY",
204
+ why: "Creates account subscriptions, top-ups and portal sessions, and is the switch that turns billing on.",
205
+ degraded:
206
+ "billing stays switched off: nothing is metered, no subscription is required, and the billing page says payments are not available",
207
+ },
208
+ {
209
+ name: "STRIPE_WEBHOOK_SECRET",
210
+ why: "Verifies payment events before granting credit.",
211
+ degraded: "no Stripe event can grant credit, so checkout cannot complete",
212
+ },
213
+ {
214
+ name: "STRIPE_MONTHLY_PRICE_ID",
215
+ why: "Pins the US$20 monthly Stripe price.",
216
+ degraded: "subscription checkout answers that the plan is not configured",
217
+ },
218
+ {
219
+ name: "FROCKBOT_ADMIN_EMAILS",
220
+ why: "The identities allowed to open Admin.",
221
+ degraded:
222
+ "nobody can administer beta access, so admission stays closed and only accounts already granted access can sign in",
223
+ },
224
+ {
225
+ name: "DEBUG_TOKEN",
226
+ why: "Authorizes the read-only `/api/debug` operator surface.",
227
+ degraded: "the operator debug routes 404",
228
+ },
229
+ {
230
+ name: "COMPOSIO_API_KEY",
231
+ why: "The project key behind Connected apps: sign-in links, connected accounts and every app tool call.",
232
+ degraded:
233
+ "no app can be connected; Connect answers that connecting apps is unavailable, and a Bot has no app tools",
234
+ },
235
+ {
236
+ name: "COMPOSIO_WEBHOOK_SECRET",
237
+ why: "HMAC-SHA256 secret that verifies Connected-app event deliveries at POST /api/connect/events.",
238
+ degraded:
239
+ "the events door answers 503; Routines cannot fire on a connected-app event",
240
+ },
241
+ {
242
+ name: "BRAVE_SEARCH_API_KEY",
243
+ why: "The Brave Search API key behind every Bot's `web_search`, billed per search.",
244
+ degraded:
245
+ "web search is unavailable: a Bot has no `web_search` tool, and `web_fetch` still reads pages",
246
+ },
247
+ {
248
+ name: "FROCK_AI_GATEWAY_TOKEN",
249
+ why: "The `cf-aig-authorization` bearer for the AI Gateway.",
250
+ degraded:
251
+ "The built-in model falls back to the `AI` binding and the Auto model fails",
252
+ },
253
+ ];
254
+
255
+ /**
256
+ * Every other string the Worker reads off `env`: a `vars` entry in
257
+ * `wrangler.jsonc`, or a door only a test harness opens. None of these is
258
+ * carried by the deploy, and each is listed so that adding a setting is a
259
+ * decision somebody made rather than one nobody noticed.
260
+ */
261
+ export const NON_SECRET_WORKER_SETTINGS_V1: readonly NonSecretWorkerSettingV1[] =
262
+ [
263
+ {
264
+ name: "NATIVE_SLICE_2_AUTH",
265
+ why: "Native sign-in returns, exactly as the profile's `nativeAuth` lists them.",
266
+ },
267
+ {
268
+ name: "NATIVE_APPS",
269
+ why: "The signed apps `assetlinks.json` and `apple-app-site-association` name, from the profile's `nativeApps`.",
270
+ },
271
+ {
272
+ name: "DEFAULT_APPLICATION_HASH",
273
+ why: "A `vars` entry the deploy writes.",
274
+ },
275
+ {
276
+ name: "ALLOWED_CLIENT_ORIGINS",
277
+ why: "Admits a cross-origin client; no deployment configures one, since the web app is same-origin and the native app sends no `Origin`.",
278
+ },
279
+ { name: "FROCK_AI_GATEWAY_ID", why: "A `vars` entry." },
280
+ {
281
+ name: "VOICE_ASSISTANT_MODEL",
282
+ why: "An optional `vars` entry pinning the gateway model the end-of-call memory update is asked; the platform's Auto route when unset.",
283
+ },
284
+ {
285
+ name: "VOICE_ASSISTANT_UPSTREAM_URL",
286
+ why: "Points the voice session at a local stand-in; set by the test harness only.",
287
+ forbiddenLive:
288
+ "every voice call would be sent to that host instead of Gemini Live",
289
+ },
290
+ {
291
+ name: "VOICE_DICTATION_CLEANUP_MODEL",
292
+ why: "An optional `vars` entry pinning the model that tidies a dictated transcript; Groq's 8B instant class when unset.",
293
+ },
294
+ {
295
+ name: "VOICE_DICTATION_UPSTREAM_URL",
296
+ why: "Points dictation at a local stand-in; set by the test harness only.",
297
+ forbiddenLive:
298
+ "every dictation session would be sent to that host instead of the speech provider",
299
+ },
300
+ {
301
+ name: "JEV_BASE_URL",
302
+ why: "Points Turn supervision at a local stand-in for Jev; set by the test harness only.",
303
+ forbiddenLive:
304
+ "every Turn's supervision would be sent to that host instead of Jev",
305
+ },
306
+ { name: "FROCK_AI_AUTO_ROUTE", why: "A `vars` entry." },
307
+ {
308
+ name: "EMAIL_DOMAIN",
309
+ why: "The `vars` entry the profile's `email` writes: the domain Email Routing hands to `email()` and every Bot sends from. Absent, every message is refused and nothing is sent.",
310
+ },
311
+ { name: "FROCK_AI_ACCOUNT_ID", why: "A `vars` entry." },
312
+ {
313
+ name: "FLOCK_AI_GATEWAY_ID",
314
+ why: "The pre-rename `vars` twin, read as a fallback.",
315
+ },
316
+ {
317
+ name: "FLOCK_AI_AUTO_ROUTE",
318
+ why: "The pre-rename `vars` twin, read as a fallback.",
319
+ },
320
+ {
321
+ name: "FLOCK_AI_ACCOUNT_ID",
322
+ why: "The pre-rename `vars` twin, read as a fallback.",
323
+ },
324
+ {
325
+ name: "FLOCK_AI_GATEWAY_TOKEN",
326
+ why: "The pre-rename secret twin; the workflow resolves it into FROCK_AI_GATEWAY_TOKEN.",
327
+ },
328
+ {
329
+ name: "ALLOW_DEVELOPMENT_AUTH",
330
+ why: "Opens the development sign-in door; never set in production.",
331
+ forbiddenLive:
332
+ "anybody reaching the deployment can sign in as any identity without Google",
333
+ },
334
+ ];
335
+
336
+ /** What this build must be given, the other auth Packages' names aside. */
337
+ export function requiredSecretsV1(
338
+ auth: ProductionAuthPackageV1 = AUTH_PACKAGE_V1,
339
+ ): ProductionSecretV1[] {
340
+ const required = REQUIRED_PRODUCTION_SECRETS_V1.filter((secret) =>
341
+ belongsToBuildV1(secret, auth),
342
+ );
343
+ if (isBuiltInAuthPackageIdV1(auth.id)) return required;
344
+ return [
345
+ ...required,
346
+ ...auth.required.map(({ name, why }) => ({ name, why })),
347
+ ];
348
+ }
349
+
350
+ /** Every name the deploy's secrets file may carry, required first. */
351
+ export function deployedSecretNamesV1(
352
+ auth: ProductionAuthPackageV1 = AUTH_PACKAGE_V1,
353
+ ): string[] {
354
+ return [
355
+ ...requiredSecretsV1(auth).map((secret) => secret.name),
356
+ ...OPTIONAL_PRODUCTION_SECRETS_V1.map((secret) => secret.name),
357
+ ];
358
+ }
359
+
360
+ /** Required names the given environment does not supply. */
361
+ export function missingRequiredSecretsV1(
362
+ present: Readonly<Record<string, string | undefined>>,
363
+ auth: ProductionAuthPackageV1 = AUTH_PACKAGE_V1,
364
+ ): ProductionSecretV1[] {
365
+ return requiredSecretsV1(auth).filter(
366
+ (secret) => (present[secret.name] ?? "").trim() === "",
367
+ );
368
+ }
369
+
370
+ /** Optional names the given environment does not supply. */
371
+ export function missingOptionalSecretsV1(
372
+ present: Readonly<Record<string, string | undefined>>,
373
+ ): OptionalProductionSecretV1[] {
374
+ return OPTIONAL_PRODUCTION_SECRETS_V1.filter(
375
+ (secret) => (present[secret.name] ?? "").trim() === "",
376
+ );
377
+ }
378
+
379
+ /**
380
+ * What a deploy of this environment actually does to the live secret set.
381
+ *
382
+ * `--secrets-file` is additive, so there are only three outcomes: a carried
383
+ * name the Worker does not hold yet is added, a carried name it already holds
384
+ * is overwritten, and everything else — the secret somebody put on by hand at
385
+ * 2am, the optional key an operator has just removed from GitHub — is left
386
+ * exactly as it is. Nothing here deletes anything, which is the point: this
387
+ * plan is what an operator reads before believing a release revoked
388
+ * something.
389
+ *
390
+ * `forbidden` is the exception that fails the release: a live secret named in
391
+ * `NON_SECRET_WORKER_SETTINGS_V1` as a door production must never have open.
392
+ * The deploy will not close it, so the gate refuses to ship over the top of
393
+ * it.
394
+ */
395
+ export interface LiveSecretPlanV1 {
396
+ readonly added: string[];
397
+ readonly updated: string[];
398
+ readonly leftInPlace: string[];
399
+ readonly forbidden: NonSecretWorkerSettingV1[];
400
+ }
401
+
402
+ /** The names this environment's deploy carries, non-empty values only. */
403
+ function carriedSecretNamesV1(
404
+ present: Readonly<Record<string, string | undefined>>,
405
+ auth: ProductionAuthPackageV1,
406
+ ): Set<string> {
407
+ return new Set(
408
+ deployedSecretNamesV1(auth).filter(
409
+ (name) => (present[name] ?? "").trim() !== "",
410
+ ),
411
+ );
412
+ }
413
+
414
+ /** What deploying this environment over that live secret set would do. */
415
+ export function liveSecretPlanV1(
416
+ live: readonly string[],
417
+ present: Readonly<Record<string, string | undefined>>,
418
+ auth: ProductionAuthPackageV1 = AUTH_PACKAGE_V1,
419
+ ): LiveSecretPlanV1 {
420
+ const carried = carriedSecretNamesV1(present, auth);
421
+ const held = new Set(live);
422
+ const forbidden = NON_SECRET_WORKER_SETTINGS_V1.filter(
423
+ (setting) => setting.forbiddenLive !== undefined && held.has(setting.name),
424
+ );
425
+ const forbiddenNames = new Set(forbidden.map((setting) => setting.name));
426
+ return {
427
+ added: [...carried].filter((name) => !held.has(name)).toSorted(),
428
+ updated: [...carried].filter((name) => held.has(name)).toSorted(),
429
+ leftInPlace: live
430
+ .filter((name) => !carried.has(name) && !forbiddenNames.has(name))
431
+ .toSorted(),
432
+ forbidden,
433
+ };
434
+ }
435
+
436
+ /** How an operator deletes one secret from the deployed Worker. */
437
+ function revokeInstructionV1(name: string): string {
438
+ return `run \`bun scripts/check-production-secrets.ts revoke ${name}\``;
439
+ }
440
+
441
+ /** The whole verdict, as the release workflow prints it. */
442
+ export function productionSecretsReportV1(
443
+ present: Readonly<Record<string, string | undefined>>,
444
+ live?: readonly string[],
445
+ auth: ProductionAuthPackageV1 = AUTH_PACKAGE_V1,
446
+ ): { ok: boolean; failures: string[]; warnings: string[]; notices: string[] } {
447
+ const failures = missingRequiredSecretsV1(present, auth).map(
448
+ (secret) =>
449
+ `Missing production configuration: ${secret.name} — ${secret.why} Add it to the repository's production environment, then re-run this release.`,
450
+ );
451
+ const plan = live ? liveSecretPlanV1(live, present, auth) : undefined;
452
+ const held = new Set(live ?? []);
453
+ const warnings = missingOptionalSecretsV1(present).map((secret) =>
454
+ held.has(secret.name)
455
+ ? `${secret.name} is unset in this release's environment, but the deployed Worker still holds it. ` +
456
+ "`wrangler deploy --secrets-file` is additive, so this release leaves the old value live and in use. " +
457
+ `Removing it from the production environment does not revoke it — ${revokeInstructionV1(secret.name)}.`
458
+ : `${secret.name} is unset, so ${secret.degraded}.`,
459
+ );
460
+ for (const setting of plan?.forbidden ?? []) {
461
+ failures.push(
462
+ `The deployed Worker holds ${setting.name}, which production must never have: ${setting.forbiddenLive}. ` +
463
+ "The deploy is additive and will not remove it, so this release stops here. " +
464
+ `${revokeInstructionV1(setting.name).replace("run", "Run")}, then re-run this release.`,
465
+ );
466
+ }
467
+ for (const name of plan?.leftInPlace ?? []) {
468
+ warnings.push(
469
+ `The deployed Worker holds a secret this release does not carry: ${name}. ` +
470
+ "The deploy is additive, so it stays live and in effect afterwards. " +
471
+ `Add it to the production environment and to this manifest so releases own it, or ${revokeInstructionV1(name)}.`,
472
+ );
473
+ }
474
+ const notices = plan
475
+ ? [
476
+ plan.added.length
477
+ ? `This deploy adds ${plan.added.length} secret(s) the Worker does not hold: ${plan.added.join(", ")}.`
478
+ : "This deploy adds no secret the Worker does not already hold.",
479
+ `It overwrites ${plan.updated.length} secret(s) the Worker already holds.`,
480
+ `It leaves ${plan.leftInPlace.length} live secret(s) untouched: a deploy never deletes a secret.`,
481
+ ]
482
+ : [];
483
+ return { ok: failures.length === 0, failures, warnings, notices };
484
+ }