@wtfalch/foundry 0.17.1 → 0.19.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 (44) hide show
  1. package/dist/bootstrap/declare.d.ts +11 -2
  2. package/dist/bootstrap/declare.js +20 -11
  3. package/dist/index.d.ts +4 -2
  4. package/dist/index.js +2 -1
  5. package/dist/migrations/0045_names_to_edge.sql +107 -0
  6. package/dist/org.d.ts +17 -17
  7. package/dist/org.js +48 -58
  8. package/dist/plan/agora.d.ts +25 -0
  9. package/dist/plan/agora.js +25 -0
  10. package/dist/plan/boule.d.ts +8 -0
  11. package/dist/plan/boule.js +8 -0
  12. package/dist/plan/diff.d.ts +3 -1
  13. package/dist/plan/diff.js +115 -52
  14. package/dist/plan/entry-edge-key.d.ts +79 -0
  15. package/dist/plan/{entry-names-key.js → entry-edge-key.js} +90 -66
  16. package/dist/plan/executor.d.ts +87 -42
  17. package/dist/plan/executor.js +277 -120
  18. package/dist/plan/index.d.ts +2 -2
  19. package/dist/plan/index.js +1 -1
  20. package/dist/plan/live.d.ts +3 -3
  21. package/dist/plan/login-host.d.ts +15 -11
  22. package/dist/plan/login-host.js +16 -16
  23. package/dist/plan/plan-apply.d.ts +6 -5
  24. package/dist/plan/plan-apply.js +40 -38
  25. package/dist/plan/platform-diff.d.ts +22 -22
  26. package/dist/plan/platform-diff.js +45 -45
  27. package/dist/plan/platform-org-ports.d.ts +1 -0
  28. package/dist/plan/platform-org-ports.js +3 -0
  29. package/dist/plan/platform-org.d.ts +8 -8
  30. package/dist/plan/platform-org.js +7 -7
  31. package/dist/plan/platform-providers.d.ts +17 -16
  32. package/dist/plan/platform-providers.js +136 -118
  33. package/dist/plan/providers-readers.d.ts +34 -11
  34. package/dist/plan/providers-readers.js +72 -15
  35. package/dist/plan/step-system.js +1 -1
  36. package/dist/plan/{edge-worker.d.ts → worker.d.ts} +53 -33
  37. package/dist/plan/{edge-worker.js → worker.js} +92 -54
  38. package/dist/platform.d.ts +3 -3
  39. package/dist/platform.js +1 -1
  40. package/dist/store/postgres.js +7 -7
  41. package/dist/store/schema.d.ts +6 -6
  42. package/dist/store/schema.js +4 -4
  43. package/package.json +1 -1
  44. package/dist/plan/entry-names-key.d.ts +0 -73
@@ -1,3 +1,4 @@
1
+ import { ZitadelError } from '@wtfalch/providers/zitadel';
1
2
  import { BOULE_MANAGED_ENV_KEYS } from './boule.js';
2
3
  /**
3
4
  * A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
@@ -8,15 +9,17 @@ import { BOULE_MANAGED_ENV_KEYS } from './boule.js';
8
9
  * resource it creates, keyed `system:kind:key` ({@link storeRecordedIds}
9
10
  * below reads those back), and #20's `FoundryStepExecutor` (`executor.ts`)
10
11
  * now also calls `StepExecutionContext.recordScopeId` for `coolify_project`,
11
- * `zitadel_org` and `zitadel_project`, so a resolver built with one of those
12
- * three `serviceKey`s returns the real id once that step has run. A
13
- * Cloudflare zone is the one scope id nothing writes yet: `executor.ts`'s
14
- * `cloudflareZone` records its result only under `cloudflare:zone:<domain>`
15
- * (a resource id, read back by {@link storeRecordedIds}), not under a plain
16
- * `cloudflare_zone` scope key — a resolver built with that `serviceKey`
17
- * still returns `null` for every org. That is the correct "nothing live yet"
18
- * answer: a reader passed `null` returns `[]`, which `computePlanDetail`
19
- * (`diff.ts`) already reads as "no live resource".
12
+ * `zitadel_org`, `zitadel_project` and (#510) `cloudflare_zone`, so a
13
+ * resolver built with one of those `serviceKey`s returns the real id once
14
+ * that step has run. Before #510, `cloudflareZone` recorded its result only
15
+ * under `cloudflare:zone:<domain>` (a resource id, read back by
16
+ * {@link storeRecordedIds}), never under the plain `cloudflare_zone` scope
17
+ * key — so a resolver built with that `serviceKey` always returned `null`,
18
+ * `ProvidersCloudflareReader.listResources` always returned `[]`, and
19
+ * `diff.ts`'s `planZone` re-proposed provisioning the zone on every apply
20
+ * even once it was live. A scope key that genuinely has nothing recorded yet
21
+ * still correctly resolves to `null`: a reader passed `null` returns `[]`,
22
+ * which `computePlanDetail` (`diff.ts`) already reads as "no live resource".
20
23
  */
21
24
  export function storeScopeResolver(store, serviceKey) {
22
25
  return async (orgId) => {
@@ -179,18 +182,34 @@ async function withEnvNamesConfig(client, resource, recorded) {
179
182
  * the `agora` kind, like {@link withLogVolumeConfig}. Unlike that read, a
180
183
  * failure here is not swallowed: a plan that cannot see the live config must
181
184
  * fail, not report it as missing.
185
+ *
186
+ * #506: also the live app's own `ports_exposes`, as `config.ports_exposes`,
187
+ * so `planAgora` can plan the fix for an app Coolify never got that setting
188
+ * on even when its env already matches -- the exact shape of the Company
189
+ * Included incident this issue names, where re-planning without this read
190
+ * would see no drift at all and never run `coolifyAgora` again.
182
191
  */
183
192
  async function withAgoraEnvConfig(client, resource, recorded) {
184
193
  if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('agora:')) {
185
194
  return resource;
186
195
  }
187
- const envs = await client.listEnvs(resource.key);
196
+ const [envs, app] = await Promise.all([
197
+ client.listEnvs(resource.key),
198
+ client.getApplication(resource.key),
199
+ ]);
188
200
  const env = {};
189
201
  for (const e of envs) {
190
202
  if (!e.is_preview && e.key.startsWith('AGORA_'))
191
203
  env[e.key] = e.value;
192
204
  }
193
- return { ...resource, config: { ...resource.config, env } };
205
+ // `@wtfalch/providers`' `CoolifyApplication` type does not declare
206
+ // `ports_exposes` (as of 0.7.3), but `getApplication` returns the raw
207
+ // Coolify API response (`coolify.ts`'s own `getApplication`, a plain
208
+ // `request` passthrough), which does carry it -- the same gap that
209
+ // repo's own CLAUDE.md names for other fields ("verified against a live
210
+ // instance, not the bundled openapi.yaml").
211
+ const portsExposes = app.ports_exposes;
212
+ return { ...resource, config: { ...resource.config, env, ports_exposes: portsExposes } };
194
213
  }
195
214
  /**
196
215
  * #161: the org's own recorded Boule app's live sign-in env vars, as
@@ -273,13 +292,17 @@ export class ProvidersCloudflareReader {
273
292
  // A zone needs no mapping: its key is already the zone name, which is
274
293
  // foundry's own zone key (`diff.ts`'s `planZone`), so it never matches a
275
294
  // recorded provider id and always passes through unchanged.
276
- return applyRecordedIds(toFoundryResources('cloudflare', resources), recorded, CLOUDFLARE_DROP_UNRECORDED);
295
+ return applyRecordedIds(toFoundryResources('cloudflare', resources)
296
+ // Keyed by the zone id, so a recorded zone id would map it onto the
297
+ // zone's own key and replace the zone's status; nothing plans from it.
298
+ .filter((resource) => resource.kind !== 'email_routing'), recorded, CLOUDFLARE_DROP_UNRECORDED);
277
299
  }
278
300
  }
279
301
  /**
280
302
  * Backed by `@wtfalch/providers/zitadel`'s `ZitadelClient.listResources`: one
281
303
  * resource per project and per project grant in a ZITADEL org (v1 scope —
282
- * see that method's own doc comment).
304
+ * see that method's own doc comment), plus (#510) a synthesized `issuer`
305
+ * resource once the org's own ZITADEL org is confirmed live.
283
306
  */
284
307
  export class ProvidersZitadelReader {
285
308
  client;
@@ -294,11 +317,45 @@ export class ProvidersZitadelReader {
294
317
  const zitadelOrgId = await this.resolveScope(orgId);
295
318
  if (!zitadelOrgId)
296
319
  return [];
297
- const [resources, recorded] = await Promise.all([
320
+ const [resources, recorded, issuer] = await Promise.all([
298
321
  this.client.listResources(zitadelOrgId),
299
322
  this.resolveRecordedIds(orgId),
323
+ this.liveIssuer(orgId, zitadelOrgId),
300
324
  ]);
301
- return applyRecordedIds(toFoundryResources('zitadel', resources), recorded);
325
+ const mapped = applyRecordedIds(toFoundryResources('zitadel', resources), recorded);
326
+ return issuer ? [...mapped, issuer] : mapped;
327
+ }
328
+ /**
329
+ * #510: `client.listResources` reports only what is *inside* a ZITADEL org
330
+ * (its projects and grants), never the org record itself, so nothing ever
331
+ * matched `diff.ts`'s `planIssuer`'s own `zitadel:issuer:<orgId>` lookup —
332
+ * every plan re-proposed "provision the org's own ZITADEL org" after a
333
+ * clean apply, even once `zitadel_org` was recorded and live. `orgId` here
334
+ * is foundry's own id ({@link resourceKey}'s `key` for an issuer, `diff.ts`'s
335
+ * `planIssuer`), not `zitadelOrgId` — the resource foundry tracks is "this
336
+ * org's issuer", and ZITADEL's own org id is an implementation detail of
337
+ * where it lives, the same distinction {@link applyRecordedIds} draws for
338
+ * every other recorded resource.
339
+ *
340
+ * `resolveScope` only reads what was last recorded; it never confirms the
341
+ * org still exists in ZITADEL. A 404 here means the recorded id no longer
342
+ * resolves (the org was removed out of band) and reads as "not live" — the
343
+ * same `[]` answer a never-recorded scope already gives. Any other failure
344
+ * propagates, same as every other call in this reader. An org ZITADEL still
345
+ * returns but marks `ORG_STATE_REMOVED` is not live either.
346
+ */
347
+ async liveIssuer(orgId, zitadelOrgId) {
348
+ try {
349
+ const found = await this.client.getOrg(zitadelOrgId);
350
+ if (found.state === 'ORG_STATE_REMOVED')
351
+ return null;
352
+ }
353
+ catch (err) {
354
+ if (err instanceof ZitadelError && err.status === 404)
355
+ return null;
356
+ throw err;
357
+ }
358
+ return { system: 'zitadel', kind: 'issuer', key: orgId, config: {} };
302
359
  }
303
360
  }
304
361
  /**
@@ -4,7 +4,7 @@ const PLATFORM_APP_SYSTEMS = {
4
4
  'platform:mail': 'stalwart',
5
5
  'platform:files': 'files',
6
6
  'platform:ai': 'ai',
7
- 'platform:names': 'names',
7
+ 'platform:edge': 'edge',
8
8
  };
9
9
  /** The system `action` touches. The one place that decides it, so no planner sets it by hand. */
10
10
  export function stepSystemOf(action) {
@@ -1,24 +1,24 @@
1
- import type { DeclaredOrg, EdgeConfig, HostingEntry } from '../org.js';
1
+ import type { DeclaredOrg, HostingEntry, WorkerConfig } from '../org.js';
2
2
  import type { DraftStep } from './diff.js';
3
3
  /**
4
4
  * F8 (#318): one plain Cloudflare Worker per hosting entry that declares
5
- * `edge`, deployed from a built artifact that entry's own repo publishes
5
+ * `worker`, deployed from a built artifact that entry's own repo publishes
6
6
  * (estate DECISIONS.md, 2026-10-04: foundry fetches and verifies it, never
7
7
  * checks it out or bundles it). Unlike `planEntryAiKeys`/`planFilesKeys`,
8
8
  * there is no renewal window -- a Worker script has no expiry -- so a step
9
9
  * is planned again only while the entry's own declared config (`route`,
10
10
  * `bindings` or, crucially, `artifactSha256`) differs from what was last
11
- * applied (recorded as that config's JSON, `edgeAppliedScopeKey`):
11
+ * applied (recorded as that config's JSON, `workerAppliedScopeKey`):
12
12
  * `artifactSha256` is itself the declared signal that the built bytes
13
13
  * changed, so a rebuild under an unchanged URL still redeploys as soon as
14
14
  * the entry declares the new digest. "Blocked, not failing" (CLAUDE.md): a
15
15
  * host whose executor carries no `WorkersPort` reports every entry's step
16
16
  * (apply or retire) as blocked, not planned to a step that would throw.
17
17
  */
18
- export declare const entryEdgeAppliedScopeKey: (entryKey: string) => string;
19
- export declare const entryEdgeRouteIdScopeKey: (entryKey: string) => string;
18
+ export declare const entryWorkerAppliedScopeKey: (entryKey: string) => string;
19
+ export declare const entryWorkerRouteIdScopeKey: (entryKey: string) => string;
20
20
  /** The route pattern the recorded route id above was created for -- read to find and delete the *old* route when the declared route pattern changes. */
21
- export declare const entryEdgeRoutePatternScopeKey: (entryKey: string) => string;
21
+ export declare const entryWorkerRoutePatternScopeKey: (entryKey: string) => string;
22
22
  /**
23
23
  * The script name retired, once a retire step completes. `runSteps` only
24
24
  * ever writes a step's main recorded id from a *truthy* `resourceId`
@@ -27,25 +27,35 @@ export declare const entryEdgeRoutePatternScopeKey: (entryKey: string) => string
27
27
  * and without this marker the "gone" scan below would plan the same retire
28
28
  * forever. Compared against that still-live id, the same convergence
29
29
  * `entryAiRetiredScopeKey`/`entryFilesRetiredScopeKey`/
30
- * `entryNamesRetiredScopeKey` already give their own entry keys.
30
+ * `entryEdgeRetiredScopeKey` already give their own entry keys.
31
31
  */
32
- export declare const entryEdgeRetiredScopeKey: (entryKey: string) => string;
32
+ export declare const entryWorkerRetiredScopeKey: (entryKey: string) => string;
33
33
  /** The script name being uploaded, recorded (and persisted) *before* the upload call -- so a crash mid-upload or a failed route create still leaves a trail to the script rather than orphaning it, and a retry reads the script as foundry's own instead of tripping the `scriptExists` overwrite guard. Cleared once the step records success. */
34
- export declare const entryEdgeUploadedScopeKey: (entryKey: string) => string;
35
- /** The zone the recorded route id lives in, as {@link EdgeRouteZone} JSON -- so retire and destroy delete it on *that* zone even after `org.domain` changes or goes null. */
36
- export declare const entryEdgeRouteZoneScopeKey: (entryKey: string) => string;
37
- /** A route about to be created, as {@link EdgeRoutePending} JSON, recorded and persisted before `createRoute` -- so a crash or failed persist right after Cloudflare creates it still leaves retire and destroy something to find it by (`findRoute`). Cleared once the route id is recorded. */
38
- export declare const entryEdgeRoutePendingScopeKey: (entryKey: string) => string;
39
- export interface EdgeRouteZone {
34
+ export declare const entryWorkerUploadedScopeKey: (entryKey: string) => string;
35
+ /** The zone the recorded route id lives in, as {@link WorkerRouteZone} JSON -- so retire and destroy delete it on *that* zone even after `org.domain` changes or goes null. */
36
+ export declare const entryWorkerRouteZoneScopeKey: (entryKey: string) => string;
37
+ /** A route about to be created, as {@link WorkerRoutePending} JSON, recorded and persisted before `createRoute` -- so a crash or failed persist right after Cloudflare creates it still leaves retire and destroy something to find it by (`findRoute`). Cleared once the route id is recorded. */
38
+ export declare const entryWorkerRoutePendingScopeKey: (entryKey: string) => string;
39
+ /**
40
+ * The no-Worker routes kept beside an entry's wildcard Worker route, as
41
+ * {@link WorkerExclusions} JSON. Written after each create or delete, so a
42
+ * crash leaves every route that exists recorded.
43
+ */
44
+ export declare const entryWorkerExclusionsScopeKey: (entryKey: string) => string;
45
+ export interface WorkerExclusions extends WorkerRouteZone {
46
+ /** Route pattern to Cloudflare route id. */
47
+ routes: Record<string, string>;
48
+ }
49
+ export interface WorkerRouteZone {
40
50
  id: string;
41
51
  apex: string;
42
52
  }
43
- export interface EdgeRoutePending extends EdgeRouteZone {
53
+ export interface WorkerRoutePending extends WorkerRouteZone {
44
54
  pattern: string;
45
55
  script: string;
46
56
  }
47
57
  /** The per-entry scope keys that mark something deployed or half-deployed, or that a retire already handled it -- cleared together once org destroy has torn the Worker down (#318 re-review: the retired marker was left out, so it stayed recorded as a stale leftover after the org itself was gone). */
48
- export declare const edgeWorkerTraceScopeKeys: (entryKey: string) => string[];
58
+ export declare const workerTraceScopeKeys: (entryKey: string) => string[];
49
59
  /**
50
60
  * Every entry key of `orgId` with something foundry deployed still live,
51
61
  * mapped to the script name recorded for it (`''` if only a route trace is
@@ -56,11 +66,11 @@ export declare const edgeWorkerTraceScopeKeys: (entryKey: string) => string[];
56
66
  * the retired marker, since an apply clears that marker before it leaves any
57
67
  * of these (#318 re-review).
58
68
  */
59
- export declare function liveEdgeWorkers(orgId: string, recordedIds: Readonly<Record<string, string>>): Map<string, string>;
60
- /** The first 8 hex characters of org.id's own sha256 -- see {@link edgeWorkerName}'s doc comment. */
61
- export declare const EDGE_WORKER_ORG_HASH_LENGTH = 8;
69
+ export declare function liveWorkers(orgId: string, recordedIds: Readonly<Record<string, string>>): Map<string, string>;
70
+ /** The first 8 hex characters of org.id's own sha256 -- see {@link workerName}'s doc comment. */
71
+ export declare const WORKER_ORG_HASH_LENGTH = 8;
62
72
  /** Cloudflare's own limit on a Worker script name. */
63
- export declare const EDGE_WORKER_NAME_MAX_LENGTH = 63;
73
+ export declare const WORKER_NAME_MAX_LENGTH = 63;
64
74
  /**
65
75
  * `${8-hex-char hash of org.id}-${entry.key}-edge`, not `${org.slug}-...`
66
76
  * (#318 re-review): a slug and an entry key both allow hyphens, so org
@@ -69,37 +79,47 @@ export declare const EDGE_WORKER_NAME_MAX_LENGTH = 63;
69
79
  * caught the second of the two to apply, and two concurrent applies could
70
80
  * both pass it. `org.id` is a UUID with no ambiguity of its own, but using
71
81
  * it in full would make the fixed part of the name longer than most of the
72
- * 63-character budget this port's `EDGE_WORKER_NAME_MAX_LENGTH` gives it,
73
- * so only a short hash of it is used -- `validateEntryEdge` (`org.ts`)
82
+ * 63-character budget this port's `WORKER_NAME_MAX_LENGTH` gives it,
83
+ * so only a short hash of it is used -- `validateEntryWorker` (`org.ts`)
74
84
  * checks the resulting name never exceeds that limit.
75
85
  */
76
- export declare const edgeWorkerName: (org: DeclaredOrg, entry: HostingEntry) => string;
86
+ export declare const workerName: (org: DeclaredOrg, entry: HostingEntry) => string;
77
87
  /**
78
88
  * Foundry's own fixed compatibility date for every Worker it deploys -- not
79
- * a per-entry declared value, so it is not part of {@link EdgeWorkerWant}
89
+ * a per-entry declared value, so it is not part of {@link WorkerWant}
80
90
  * and never triggers a re-plan on its own; bumping it is a foundry release,
81
91
  * like {@link RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS} is a fixed Cloudflare
82
92
  * Free plan value rather than something an org declares.
83
93
  */
84
- export declare const EDGE_WORKER_COMPATIBILITY_DATE = "2026-09-12";
85
- export type EdgeWorkerMode = 'apply' | 'retire';
86
- export interface EdgeWorkerWant {
94
+ export declare const WORKER_COMPATIBILITY_DATE = "2026-09-12";
95
+ export type WorkerMode = 'apply' | 'retire';
96
+ export interface WorkerWant {
87
97
  name: string;
88
98
  artifactUrl: string;
89
99
  artifactSha256: string;
90
100
  route: string;
91
- bindings: EdgeConfig['bindings'];
101
+ bindings: WorkerConfig['bindings'];
102
+ /** No-Worker route patterns, sorted; set only for a wildcard route with hostnames to keep out. */
103
+ exclusions?: string[];
92
104
  }
93
- export declare function wantedEdgeWorker(org: DeclaredOrg, entry: HostingEntry): EdgeWorkerWant;
105
+ /**
106
+ * With a wildcard route (`*.<domain>/*`) the Worker would also capture every
107
+ * hostname foundry provisions on the domain, so each gets a no-Worker route
108
+ * `<hostname>/*` (a more specific pattern wins): every entry hostname plus the
109
+ * names `lockedHostnames` lists (Agora, Boule, the login host, platform apps).
110
+ * Never the wildcard itself.
111
+ */
112
+ export declare function workerExclusions(org: DeclaredOrg, entry: HostingEntry, platformHostnames: readonly string[]): string[];
113
+ export declare function wantedWorker(org: DeclaredOrg, entry: HostingEntry, platformHostnames?: readonly string[]): WorkerWant;
94
114
  type Blocked = Array<{
95
115
  description: string;
96
116
  key: string;
97
117
  }>;
98
118
  /**
99
- * One step per entry whose `edge` config is new or changed, plus one retire
119
+ * One step per entry whose `worker` config is new or changed, plus one retire
100
120
  * step per entry whose Worker is recorded but no longer declared -- either
101
- * because the entry dropped `edge` or because the entry itself is gone, the
102
- * same "gone" scan `planEntryAiKeys`/`planFilesKeys`/`planEntryNamesKeys`
121
+ * because the entry dropped `worker` or because the entry itself is gone, the
122
+ * same "gone" scan `planEntryAiKeys`/`planFilesKeys`/`planEntryEdgeKeys`
103
123
  * run over their own recorded keys, so removing a whole entry still cleans
104
124
  * up its Worker. `workersAvailable` is the host's own
105
125
  * `FoundryPlanApplyOptions.edgeWorkers` (mirrors `jobsAvailable`): without
@@ -107,7 +127,7 @@ type Blocked = Array<{
107
127
  * than planned to fail at apply. The caller (`diff.ts`) gates the *apply*
108
128
  * steps further, on an active zone, the same way it gates `planRateLimit`.
109
129
  */
110
- export declare function planEdgeWorkers(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, workersAvailable: boolean): {
130
+ export declare function planWorkers(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, workersAvailable: boolean, platformHostnames?: readonly string[]): {
111
131
  steps: DraftStep[];
112
132
  blocked: Blocked;
113
133
  };
@@ -1,24 +1,27 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { entryHostnames } from './hostnames.js';
3
+ import { lockedHostnames } from './locked-hostnames.js';
2
4
  import { appStepKey } from './platform-diff.js';
3
5
  /**
4
6
  * F8 (#318): one plain Cloudflare Worker per hosting entry that declares
5
- * `edge`, deployed from a built artifact that entry's own repo publishes
7
+ * `worker`, deployed from a built artifact that entry's own repo publishes
6
8
  * (estate DECISIONS.md, 2026-10-04: foundry fetches and verifies it, never
7
9
  * checks it out or bundles it). Unlike `planEntryAiKeys`/`planFilesKeys`,
8
10
  * there is no renewal window -- a Worker script has no expiry -- so a step
9
11
  * is planned again only while the entry's own declared config (`route`,
10
12
  * `bindings` or, crucially, `artifactSha256`) differs from what was last
11
- * applied (recorded as that config's JSON, `edgeAppliedScopeKey`):
13
+ * applied (recorded as that config's JSON, `workerAppliedScopeKey`):
12
14
  * `artifactSha256` is itself the declared signal that the built bytes
13
15
  * changed, so a rebuild under an unchanged URL still redeploys as soon as
14
16
  * the entry declares the new digest. "Blocked, not failing" (CLAUDE.md): a
15
17
  * host whose executor carries no `WorkersPort` reports every entry's step
16
18
  * (apply or retire) as blocked, not planned to a step that would throw.
17
19
  */
18
- export const entryEdgeAppliedScopeKey = (entryKey) => `edge_worker_applied_${entryKey}`;
19
- export const entryEdgeRouteIdScopeKey = (entryKey) => `edge_worker_route_id_${entryKey}`;
20
+ // Recorded scope keys keep their `edge_worker_` prefix: renaming them would orphan live Workers.
21
+ export const entryWorkerAppliedScopeKey = (entryKey) => `edge_worker_applied_${entryKey}`;
22
+ export const entryWorkerRouteIdScopeKey = (entryKey) => `edge_worker_route_id_${entryKey}`;
20
23
  /** The route pattern the recorded route id above was created for -- read to find and delete the *old* route when the declared route pattern changes. */
21
- export const entryEdgeRoutePatternScopeKey = (entryKey) => `edge_worker_route_pattern_${entryKey}`;
24
+ export const entryWorkerRoutePatternScopeKey = (entryKey) => `edge_worker_route_pattern_${entryKey}`;
22
25
  /**
23
26
  * The script name retired, once a retire step completes. `runSteps` only
24
27
  * ever writes a step's main recorded id from a *truthy* `resourceId`
@@ -27,23 +30,30 @@ export const entryEdgeRoutePatternScopeKey = (entryKey) => `edge_worker_route_pa
27
30
  * and without this marker the "gone" scan below would plan the same retire
28
31
  * forever. Compared against that still-live id, the same convergence
29
32
  * `entryAiRetiredScopeKey`/`entryFilesRetiredScopeKey`/
30
- * `entryNamesRetiredScopeKey` already give their own entry keys.
33
+ * `entryEdgeRetiredScopeKey` already give their own entry keys.
31
34
  */
32
- export const entryEdgeRetiredScopeKey = (entryKey) => `edge_worker_retired_${entryKey}`;
35
+ export const entryWorkerRetiredScopeKey = (entryKey) => `edge_worker_retired_${entryKey}`;
33
36
  /** The script name being uploaded, recorded (and persisted) *before* the upload call -- so a crash mid-upload or a failed route create still leaves a trail to the script rather than orphaning it, and a retry reads the script as foundry's own instead of tripping the `scriptExists` overwrite guard. Cleared once the step records success. */
34
- export const entryEdgeUploadedScopeKey = (entryKey) => `edge_worker_uploaded_${entryKey}`;
35
- /** The zone the recorded route id lives in, as {@link EdgeRouteZone} JSON -- so retire and destroy delete it on *that* zone even after `org.domain` changes or goes null. */
36
- export const entryEdgeRouteZoneScopeKey = (entryKey) => `edge_worker_route_zone_${entryKey}`;
37
- /** A route about to be created, as {@link EdgeRoutePending} JSON, recorded and persisted before `createRoute` -- so a crash or failed persist right after Cloudflare creates it still leaves retire and destroy something to find it by (`findRoute`). Cleared once the route id is recorded. */
38
- export const entryEdgeRoutePendingScopeKey = (entryKey) => `edge_worker_route_pending_${entryKey}`;
37
+ export const entryWorkerUploadedScopeKey = (entryKey) => `edge_worker_uploaded_${entryKey}`;
38
+ /** The zone the recorded route id lives in, as {@link WorkerRouteZone} JSON -- so retire and destroy delete it on *that* zone even after `org.domain` changes or goes null. */
39
+ export const entryWorkerRouteZoneScopeKey = (entryKey) => `edge_worker_route_zone_${entryKey}`;
40
+ /** A route about to be created, as {@link WorkerRoutePending} JSON, recorded and persisted before `createRoute` -- so a crash or failed persist right after Cloudflare creates it still leaves retire and destroy something to find it by (`findRoute`). Cleared once the route id is recorded. */
41
+ export const entryWorkerRoutePendingScopeKey = (entryKey) => `edge_worker_route_pending_${entryKey}`;
42
+ /**
43
+ * The no-Worker routes kept beside an entry's wildcard Worker route, as
44
+ * {@link WorkerExclusions} JSON. Written after each create or delete, so a
45
+ * crash leaves every route that exists recorded.
46
+ */
47
+ export const entryWorkerExclusionsScopeKey = (entryKey) => `edge_worker_exclusions_${entryKey}`;
39
48
  /** The per-entry scope keys that mark something deployed or half-deployed, or that a retire already handled it -- cleared together once org destroy has torn the Worker down (#318 re-review: the retired marker was left out, so it stayed recorded as a stale leftover after the org itself was gone). */
40
- export const edgeWorkerTraceScopeKeys = (entryKey) => [
41
- entryEdgeUploadedScopeKey(entryKey),
42
- entryEdgeRouteIdScopeKey(entryKey),
43
- entryEdgeRouteZoneScopeKey(entryKey),
44
- entryEdgeRoutePatternScopeKey(entryKey),
45
- entryEdgeRoutePendingScopeKey(entryKey),
46
- entryEdgeRetiredScopeKey(entryKey),
49
+ export const workerTraceScopeKeys = (entryKey) => [
50
+ entryWorkerUploadedScopeKey(entryKey),
51
+ entryWorkerRouteIdScopeKey(entryKey),
52
+ entryWorkerRouteZoneScopeKey(entryKey),
53
+ entryWorkerRoutePatternScopeKey(entryKey),
54
+ entryWorkerRoutePendingScopeKey(entryKey),
55
+ entryWorkerRetiredScopeKey(entryKey),
56
+ entryWorkerExclusionsScopeKey(entryKey),
47
57
  ];
48
58
  /**
49
59
  * Every entry key of `orgId` with something foundry deployed still live,
@@ -55,13 +65,14 @@ export const edgeWorkerTraceScopeKeys = (entryKey) => [
55
65
  * the retired marker, since an apply clears that marker before it leaves any
56
66
  * of these (#318 re-review).
57
67
  */
58
- export function liveEdgeWorkers(orgId, recordedIds) {
68
+ export function liveWorkers(orgId, recordedIds) {
59
69
  const mainPrefix = `cloudflare:worker:${orgId}:`;
60
70
  const tracePrefixes = [
61
71
  mainPrefix,
62
72
  'edge_worker_uploaded_',
63
73
  'edge_worker_route_id_',
64
74
  'edge_worker_route_pending_',
75
+ 'edge_worker_exclusions_',
65
76
  ];
66
77
  const entryKeys = new Set();
67
78
  for (const [name, id] of Object.entries(recordedIds)) {
@@ -74,21 +85,22 @@ export function liveEdgeWorkers(orgId, recordedIds) {
74
85
  const live = new Map();
75
86
  for (const entryKey of entryKeys) {
76
87
  const mainId = recordedIds[`${mainPrefix}${entryKey}`] ?? '';
77
- const uploaded = recordedIds[entryEdgeUploadedScopeKey(entryKey)] ?? '';
88
+ const uploaded = recordedIds[entryWorkerUploadedScopeKey(entryKey)] ?? '';
78
89
  const partial = uploaded ||
79
- recordedIds[entryEdgeRouteIdScopeKey(entryKey)] ||
80
- recordedIds[entryEdgeRoutePendingScopeKey(entryKey)];
81
- const retired = mainId !== '' && recordedIds[entryEdgeRetiredScopeKey(entryKey)] === mainId;
90
+ recordedIds[entryWorkerRouteIdScopeKey(entryKey)] ||
91
+ recordedIds[entryWorkerRoutePendingScopeKey(entryKey)] ||
92
+ recordedIds[entryWorkerExclusionsScopeKey(entryKey)];
93
+ const retired = mainId !== '' && recordedIds[entryWorkerRetiredScopeKey(entryKey)] === mainId;
82
94
  if (!partial && (!mainId || retired))
83
95
  continue;
84
96
  live.set(entryKey, mainId || uploaded);
85
97
  }
86
98
  return live;
87
99
  }
88
- /** The first 8 hex characters of org.id's own sha256 -- see {@link edgeWorkerName}'s doc comment. */
89
- export const EDGE_WORKER_ORG_HASH_LENGTH = 8;
100
+ /** The first 8 hex characters of org.id's own sha256 -- see {@link workerName}'s doc comment. */
101
+ export const WORKER_ORG_HASH_LENGTH = 8;
90
102
  /** Cloudflare's own limit on a Worker script name. */
91
- export const EDGE_WORKER_NAME_MAX_LENGTH = 63;
103
+ export const WORKER_NAME_MAX_LENGTH = 63;
92
104
  /**
93
105
  * `${8-hex-char hash of org.id}-${entry.key}-edge`, not `${org.slug}-...`
94
106
  * (#318 re-review): a slug and an entry key both allow hyphens, so org
@@ -97,34 +109,60 @@ export const EDGE_WORKER_NAME_MAX_LENGTH = 63;
97
109
  * caught the second of the two to apply, and two concurrent applies could
98
110
  * both pass it. `org.id` is a UUID with no ambiguity of its own, but using
99
111
  * it in full would make the fixed part of the name longer than most of the
100
- * 63-character budget this port's `EDGE_WORKER_NAME_MAX_LENGTH` gives it,
101
- * so only a short hash of it is used -- `validateEntryEdge` (`org.ts`)
112
+ * 63-character budget this port's `WORKER_NAME_MAX_LENGTH` gives it,
113
+ * so only a short hash of it is used -- `validateEntryWorker` (`org.ts`)
102
114
  * checks the resulting name never exceeds that limit.
103
115
  */
104
- export const edgeWorkerName = (org, entry) => `${createHash('sha256').update(org.id).digest('hex').slice(0, EDGE_WORKER_ORG_HASH_LENGTH)}-${entry.key}-edge`;
116
+ export const workerName = (org, entry) =>
117
+ // The `-edge` suffix stays after the entry field rename to `worker`, so live Workers keep their names and are not orphaned.
118
+ `${createHash('sha256').update(org.id).digest('hex').slice(0, WORKER_ORG_HASH_LENGTH)}-${entry.key}-edge`;
105
119
  /**
106
120
  * Foundry's own fixed compatibility date for every Worker it deploys -- not
107
- * a per-entry declared value, so it is not part of {@link EdgeWorkerWant}
121
+ * a per-entry declared value, so it is not part of {@link WorkerWant}
108
122
  * and never triggers a re-plan on its own; bumping it is a foundry release,
109
123
  * like {@link RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS} is a fixed Cloudflare
110
124
  * Free plan value rather than something an org declares.
111
125
  */
112
- export const EDGE_WORKER_COMPATIBILITY_DATE = '2026-09-12';
113
- export function wantedEdgeWorker(org, entry) {
114
- const edge = entry.edge;
126
+ export const WORKER_COMPATIBILITY_DATE = '2026-09-12';
127
+ /**
128
+ * With a wildcard route (`*.<domain>/*`) the Worker would also capture every
129
+ * hostname foundry provisions on the domain, so each gets a no-Worker route
130
+ * `<hostname>/*` (a more specific pattern wins): every entry hostname plus the
131
+ * names `lockedHostnames` lists (Agora, Boule, the login host, platform apps).
132
+ * Never the wildcard itself.
133
+ */
134
+ export function workerExclusions(org, entry, platformHostnames) {
135
+ const route = entry.worker?.route;
136
+ const { domain } = org;
137
+ if (!route || !domain || !route.startsWith('*.'))
138
+ return [];
139
+ const names = new Set(lockedHostnames(org, platformHostnames));
140
+ for (const e of org.hosting.entries)
141
+ for (const h of entryHostnames(org, e))
142
+ names.add(h);
143
+ return [...names]
144
+ .filter((h) => h === domain || h.endsWith(`.${domain}`))
145
+ .map((h) => `${h}/*`)
146
+ .filter((pattern) => pattern !== route)
147
+ .sort();
148
+ }
149
+ export function wantedWorker(org, entry, platformHostnames = []) {
150
+ const worker = entry.worker;
151
+ const exclusions = workerExclusions(org, entry, platformHostnames);
115
152
  return {
116
- name: edgeWorkerName(org, entry),
117
- artifactUrl: edge.artifactUrl,
118
- artifactSha256: edge.artifactSha256,
119
- route: edge.route,
120
- bindings: edge.bindings,
153
+ name: workerName(org, entry),
154
+ artifactUrl: worker.artifactUrl,
155
+ artifactSha256: worker.artifactSha256,
156
+ route: worker.route,
157
+ bindings: worker.bindings,
158
+ exclusions: exclusions.length > 0 ? exclusions : undefined,
121
159
  };
122
160
  }
123
161
  /**
124
- * One step per entry whose `edge` config is new or changed, plus one retire
162
+ * One step per entry whose `worker` config is new or changed, plus one retire
125
163
  * step per entry whose Worker is recorded but no longer declared -- either
126
- * because the entry dropped `edge` or because the entry itself is gone, the
127
- * same "gone" scan `planEntryAiKeys`/`planFilesKeys`/`planEntryNamesKeys`
164
+ * because the entry dropped `worker` or because the entry itself is gone, the
165
+ * same "gone" scan `planEntryAiKeys`/`planFilesKeys`/`planEntryEdgeKeys`
128
166
  * run over their own recorded keys, so removing a whole entry still cleans
129
167
  * up its Worker. `workersAvailable` is the host's own
130
168
  * `FoundryPlanApplyOptions.edgeWorkers` (mirrors `jobsAvailable`): without
@@ -132,24 +170,24 @@ export function wantedEdgeWorker(org, entry) {
132
170
  * than planned to fail at apply. The caller (`diff.ts`) gates the *apply*
133
171
  * steps further, on an active zone, the same way it gates `planRateLimit`.
134
172
  */
135
- export function planEdgeWorkers(org, recordedIds, workersAvailable) {
173
+ export function planWorkers(org, recordedIds, workersAvailable, platformHostnames = []) {
136
174
  const steps = [];
137
175
  const blocked = [];
138
176
  // Hosting off declares no Worker at all: the gone-scan below still runs,
139
177
  // so a Worker deployed while hosting was on is retired rather than left
140
178
  // live forever (#318 re-review). Only planning a *new* deploy is skipped.
141
- const declared = new Set(org.hosting.enabled ? org.hosting.entries.filter((e) => e.edge).map((e) => e.key) : []);
179
+ const declared = new Set(org.hosting.enabled ? org.hosting.entries.filter((e) => e.worker).map((e) => e.key) : []);
142
180
  const mainPrefix = `cloudflare:worker:${org.id}:`;
143
- // Every entryKey with a live, undeclared trace (`liveEdgeWorkers`): the
181
+ // Every entryKey with a live, undeclared trace (`liveWorkers`): the
144
182
  // main id or any partial-deploy marker, so a deploy that failed after
145
183
  // upload or mid-route-create is still found to retire (#318 review).
146
- for (const entryKey of liveEdgeWorkers(org.id, recordedIds).keys()) {
184
+ for (const entryKey of liveWorkers(org.id, recordedIds).keys()) {
147
185
  if (declared.has(entryKey))
148
186
  continue;
149
187
  const stepKey = `${org.id}:${entryKey}`;
150
188
  if (!workersAvailable) {
151
189
  blocked.push({
152
- description: `${org.slug}'s ${entryKey} edge Worker is blocked, not planned: the host supplies no WorkersPort`,
190
+ description: `${org.slug}'s ${entryKey} Worker is blocked, not planned: the host supplies no WorkersPort`,
153
191
  key: `${mainPrefix}${entryKey}`,
154
192
  });
155
193
  continue;
@@ -157,7 +195,7 @@ export function planEdgeWorkers(org, recordedIds, workersAvailable) {
157
195
  steps.push({
158
196
  action: { system: 'cloudflare', kind: 'worker', key: stepKey, desired: { mode: 'retire' } },
159
197
  step: {
160
- description: `delete ${org.slug}'s ${entryKey} edge Worker and its route: edge is no longer declared`,
198
+ description: `delete ${org.slug}'s ${entryKey} Worker and its route: worker is no longer declared`,
161
199
  destroys: true,
162
200
  migrates: false,
163
201
  },
@@ -166,11 +204,11 @@ export function planEdgeWorkers(org, recordedIds, workersAvailable) {
166
204
  if (!org.hosting.enabled)
167
205
  return { steps, blocked };
168
206
  for (const entry of org.hosting.entries) {
169
- if (!entry.edge)
207
+ if (!entry.worker)
170
208
  continue;
171
209
  const stepKey = appStepKey(org, entry.key);
172
210
  const key = `cloudflare:worker:${stepKey}`;
173
- const at = `${org.slug}'s ${entry.key} edge Worker`;
211
+ const at = `${org.slug}'s ${entry.key} Worker`;
174
212
  if (!workersAvailable) {
175
213
  blocked.push({
176
214
  description: `${at} is blocked, not planned: the host supplies no WorkersPort`,
@@ -178,9 +216,9 @@ export function planEdgeWorkers(org, recordedIds, workersAvailable) {
178
216
  });
179
217
  continue;
180
218
  }
181
- const want = wantedEdgeWorker(org, entry);
219
+ const want = wantedWorker(org, entry, platformHostnames);
182
220
  const wantJson = JSON.stringify(want);
183
- const applied = recordedIds[entryEdgeAppliedScopeKey(entry.key)];
221
+ const applied = recordedIds[entryWorkerAppliedScopeKey(entry.key)];
184
222
  if (applied === wantJson)
185
223
  continue; // Rerun: nothing changed, down to the built artifact's own digest.
186
224
  steps.push({
@@ -188,10 +226,10 @@ export function planEdgeWorkers(org, recordedIds, workersAvailable) {
188
226
  system: 'cloudflare',
189
227
  kind: 'worker',
190
228
  key: stepKey,
191
- desired: { mode: 'apply', compatibilityDate: EDGE_WORKER_COMPATIBILITY_DATE, ...want },
229
+ desired: { mode: 'apply', compatibilityDate: WORKER_COMPATIBILITY_DATE, ...want },
192
230
  },
193
231
  step: {
194
- description: `${applied ? 'update' : 'deploy'} ${at} on ${entry.edge.route}`,
232
+ description: `${applied ? 'update' : 'deploy'} ${at} on ${entry.worker.route}`,
195
233
  destroys: false,
196
234
  migrates: false,
197
235
  },
@@ -26,11 +26,11 @@ export interface PlatformMailService extends PlatformService {
26
26
  }
27
27
  /**
28
28
  * The shared services a declared platform names, decided in #165, plus
29
- * `names` (#317, F7): the service that mints an entry's own names key and
29
+ * `edge` (#317, F7): the service that mints an entry's own edge key and
30
30
  * holds the Cloudflare token `<username>.<org domain>` claims use, so no
31
31
  * hosting entry ever holds that credential itself.
32
32
  */
33
- export type PlatformServiceKey = 'auth' | 'mail' | 'sfu' | 'files' | 'ai' | 'jobs' | 'integrations' | 'archon' | 'names';
33
+ export type PlatformServiceKey = 'auth' | 'mail' | 'sfu' | 'files' | 'ai' | 'jobs' | 'integrations' | 'archon' | 'edge';
34
34
  export declare const PLATFORM_SERVICE_KEYS: readonly PlatformServiceKey[];
35
35
  /**
36
36
  * The one shared platform every org's services run on -- decided in #165 as
@@ -67,7 +67,7 @@ export interface DeclaredPlatform {
67
67
  integrations: PlatformService;
68
68
  archon: PlatformService;
69
69
  /** #317 (F7). No deploy step reads this service's own `gitRef` yet -- `N1` is deployed by hand until it exists. */
70
- names: PlatformService;
70
+ edge: PlatformService;
71
71
  }
72
72
  /** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
73
73
  export declare function defaultPlatformHostname(service: PlatformServiceKey, apex: string): string;
package/dist/platform.js CHANGED
@@ -9,7 +9,7 @@ export const PLATFORM_SERVICE_KEYS = [
9
9
  'jobs',
10
10
  'integrations',
11
11
  'archon',
12
- 'names',
12
+ 'edge',
13
13
  ];
14
14
  /** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
15
15
  export function defaultPlatformHostname(service, apex) {