@wtfalch/foundry 0.17.1 → 0.18.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.
@@ -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) {
@@ -598,10 +598,10 @@ function rowToPlatform(row) {
598
598
  hostname: row.archonHostname,
599
599
  gitRef: row.archonGitRef,
600
600
  },
601
- names: {
602
- enabled: row.namesEnabled,
603
- hostname: row.namesHostname,
604
- gitRef: row.namesGitRef,
601
+ edge: {
602
+ enabled: row.edgeEnabled,
603
+ hostname: row.edgeHostname,
604
+ gitRef: row.edgeGitRef,
605
605
  },
606
606
  };
607
607
  }
@@ -636,9 +636,9 @@ function platformToRow(platform) {
636
636
  archonEnabled: platform.archon.enabled,
637
637
  archonHostname: platform.archon.hostname,
638
638
  archonGitRef: platform.archon.gitRef,
639
- namesEnabled: platform.names.enabled,
640
- namesHostname: platform.names.hostname,
641
- namesGitRef: platform.names.gitRef,
639
+ edgeEnabled: platform.edge.enabled,
640
+ edgeHostname: platform.edge.hostname,
641
+ edgeGitRef: platform.edge.gitRef,
642
642
  };
643
643
  }
644
644
  /**
@@ -1881,8 +1881,8 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
1881
1881
  identity: undefined;
1882
1882
  generated: undefined;
1883
1883
  }, {}, {}>;
1884
- namesEnabled: import("drizzle-orm/pg-core").PgColumn<{
1885
- name: "names_enabled";
1884
+ edgeEnabled: import("drizzle-orm/pg-core").PgColumn<{
1885
+ name: "edge_enabled";
1886
1886
  tableName: "foundry_declared_platform";
1887
1887
  dataType: "boolean";
1888
1888
  columnType: "PgBoolean";
@@ -1898,8 +1898,8 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
1898
1898
  identity: undefined;
1899
1899
  generated: undefined;
1900
1900
  }, {}, {}>;
1901
- namesHostname: import("drizzle-orm/pg-core").PgColumn<{
1902
- name: "names_hostname";
1901
+ edgeHostname: import("drizzle-orm/pg-core").PgColumn<{
1902
+ name: "edge_hostname";
1903
1903
  tableName: "foundry_declared_platform";
1904
1904
  dataType: "string";
1905
1905
  columnType: "PgText";
@@ -1915,8 +1915,8 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
1915
1915
  identity: undefined;
1916
1916
  generated: undefined;
1917
1917
  }, {}, {}>;
1918
- namesGitRef: import("drizzle-orm/pg-core").PgColumn<{
1919
- name: "names_git_ref";
1918
+ edgeGitRef: import("drizzle-orm/pg-core").PgColumn<{
1919
+ name: "edge_git_ref";
1920
1920
  tableName: "foundry_declared_platform";
1921
1921
  dataType: "string";
1922
1922
  columnType: "PgText";
@@ -178,10 +178,10 @@ export const foundryDeclaredPlatform = pgTable('foundry_declared_platform', {
178
178
  archonEnabled: boolean('archon_enabled').notNull().default(false),
179
179
  archonHostname: text('archon_hostname').notNull(),
180
180
  archonGitRef: text('archon_git_ref').notNull().default(''),
181
- /** #317 (F7), `migrations/0038_platform_names.sql`. */
182
- namesEnabled: boolean('names_enabled').notNull().default(false),
183
- namesHostname: text('names_hostname').notNull(),
184
- namesGitRef: text('names_git_ref').notNull().default(''),
181
+ /** #317 (F7), `migrations/0043_platform_names.sql`, renamed from `names_*` by `0045_names_to_edge.sql`. */
182
+ edgeEnabled: boolean('edge_enabled').notNull().default(false),
183
+ edgeHostname: text('edge_hostname').notNull(),
184
+ edgeGitRef: text('edge_git_ref').notNull().default(''),
185
185
  /** `migrations/0018_platform_apply.sql`: the platform's applied state, `system:kind:key` to provider id. */
186
186
  serviceIds: jsonb('service_ids').$type().notNull().default({}),
187
187
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,73 +0,0 @@
1
- import type { DeclaredOrg, HostingEntry } from '../org.js';
2
- import type { DraftStep } from './diff.js';
3
- import type { OrgPlatformReadiness } from './platform-org.js';
4
- /**
5
- * F7 (#317): one names key per entry with `uses.names`, minted directly
6
- * below the org with the names service (`NamesPort.mintKey({
7
- * organisationId, ... })` -- no org-level root key the way files/ai have
8
- * one, since the names service takes the org id itself). `NAMES_URL` and
9
- * `NAMES_API_KEY` go to the entry's env and nowhere else. A names key has no
10
- * declared renewal window in the issue, so unlike `planEntryAiKeys`/
11
- * `planFilesKeys` this mints once and never renews.
12
- *
13
- * `NAMES_*` is written in this step, after the app's own `coolify:app`
14
- * create (which never knows the key yet), so the app's first boot has
15
- * neither and its first deploy restarts once this step's env write lands --
16
- * the same one-extra-restart shape `planEntryAiKeys`'s `AI_*`/
17
- * `planFilesKeys`'s `FILES_*` already have, not a names-specific gap.
18
- *
19
- * A domain change after mint is not detected (#317 re-review): the key is
20
- * bound to the domain passed at mint time, but nothing here records that
21
- * domain to compare against a later `org.domain`, so an org that changes
22
- * domains keeps a names key still bound to the old one. Not built --
23
- * resolving it needs a manual revoke-and-re-mint (drop `uses.names`, apply,
24
- * re-add it) until this is.
25
- */
26
- export declare const entryNamesUndeliveredScopeKey: (entryKey: string) => string;
27
- /** The id of a key that was live when its entry stopped declaring `uses.names`, once handled. */
28
- export declare const entryNamesRetiredScopeKey: (entryKey: string) => string;
29
- export declare const ENTRY_NAMES_KEY_DELIVERY_ATTEMPTS = 3;
30
- /** How long an entry's names key lives. The issue names no renewal window, so this mints once and never renews. */
31
- export declare const NAMES_ENTRY_KEY_DAYS = 365;
32
- /** The ISO time a delivered key's mint succeeded -- the only trace of its age, since there is no renewal to track against. */
33
- export declare const entryNamesMintedAtScopeKey: (entryKey: string) => string;
34
- /** A key with fewer days left than this is surfaced as a blocked warning row (#317 review): still live, but nothing here renews it. */
35
- export declare const NAMES_ENTRY_KEY_WARN_WINDOW_DAYS = 30;
36
- /** The reserved labels last sent to the names service, as that sorted list's own JSON. */
37
- export declare const NAMES_RESERVED_LABELS_APPLIED_SCOPE_KEY = "names_reserved_labels_applied";
38
- export declare const entryNamesKeyName: (org: DeclaredOrg, entry: HostingEntry) => string;
39
- /**
40
- * Every label a declared entry serves (`@`/`*` reserve nothing of their own:
41
- * the bare domain and the wildcard are not names a customer could claim
42
- * under names anyway), plus `www`, which `setReservedLabels`'s own doc
43
- * comment in `executor.ts` explains is reserved unconditionally.
44
- */
45
- export declare function namesReservedLabels(org: DeclaredOrg): string[];
46
- type Blocked = Array<{
47
- description: string;
48
- key: string;
49
- }>;
50
- /**
51
- * One step per entry whose names key is missing, plus one retire step per
52
- * entry whose `uses.names` was removed but still has a recorded key.
53
- * Blocked, not planned (rule 2), while the platform has no names service
54
- * wired, the entry's app is not recorded, or a previous mint's delivery
55
- * failed.
56
- */
57
- export declare function planEntryNamesKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], readiness: OrgPlatformReadiness, now?: number): {
58
- steps: DraftStep[];
59
- blocked: Blocked;
60
- };
61
- /**
62
- * The reserved-labels step: resent whenever the org's own label set
63
- * (`namesReservedLabels`) differs from what was last sent. While the org
64
- * uses names at all (at least one entry with `uses.names`) that set is
65
- * `namesReservedLabels(org)`; once the last entry drops `uses.names`, this
66
- * clears whatever was last sent (an empty list) rather than leaving it
67
- * stuck in names forever -- a one-time step, same as any other change.
68
- */
69
- export declare function planNamesReservedLabels(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, readiness: OrgPlatformReadiness): {
70
- step: DraftStep | null;
71
- blocked: Blocked;
72
- };
73
- export {};