@wtfalch/foundry 0.17.0 → 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.
Files changed (38) hide show
  1. package/dist/bootstrap/declare.js +6 -4
  2. package/dist/index.d.ts +2 -2
  3. package/dist/index.js +1 -1
  4. package/dist/migrations/0043_platform_names.sql +25 -0
  5. package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
  6. package/dist/migrations/0045_names_to_edge.sql +107 -0
  7. package/dist/org.d.ts +74 -3
  8. package/dist/org.js +133 -0
  9. package/dist/plan/chat-matrix.d.ts +0 -1
  10. package/dist/plan/chat-matrix.js +4 -1
  11. package/dist/plan/diff.d.ts +18 -0
  12. package/dist/plan/diff.js +239 -26
  13. package/dist/plan/entry-edge-key.d.ts +79 -0
  14. package/dist/plan/entry-edge-key.js +260 -0
  15. package/dist/plan/executor.d.ts +246 -1
  16. package/dist/plan/executor.js +684 -32
  17. package/dist/plan/index.d.ts +2 -2
  18. package/dist/plan/index.js +1 -1
  19. package/dist/plan/live.d.ts +4 -2
  20. package/dist/plan/login-host.d.ts +12 -1
  21. package/dist/plan/login-host.js +20 -2
  22. package/dist/plan/plan-apply.d.ts +15 -0
  23. package/dist/plan/plan-apply.js +102 -9
  24. package/dist/plan/platform-diff.d.ts +92 -1
  25. package/dist/plan/platform-diff.js +166 -1
  26. package/dist/plan/platform-org.d.ts +29 -1
  27. package/dist/plan/platform-org.js +17 -2
  28. package/dist/plan/platform-providers.d.ts +45 -1
  29. package/dist/plan/platform-providers.js +514 -7
  30. package/dist/plan/step-system.js +1 -0
  31. package/dist/plan/worker.d.ts +134 -0
  32. package/dist/plan/worker.js +239 -0
  33. package/dist/platform.d.ts +22 -3
  34. package/dist/platform.js +6 -0
  35. package/dist/store/postgres.js +15 -1
  36. package/dist/store/schema.d.ts +70 -2
  37. package/dist/store/schema.js +6 -0
  38. package/package.json +1 -1
@@ -4,6 +4,7 @@ const PLATFORM_APP_SYSTEMS = {
4
4
  'platform:mail': 'stalwart',
5
5
  'platform:files': 'files',
6
6
  'platform:ai': 'ai',
7
+ 'platform:edge': 'edge',
7
8
  };
8
9
  /** The system `action` touches. The one place that decides it, so no planner sets it by hand. */
9
10
  export function stepSystemOf(action) {
@@ -0,0 +1,134 @@
1
+ import type { DeclaredOrg, HostingEntry, WorkerConfig } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ /**
4
+ * F8 (#318): one plain Cloudflare Worker per hosting entry that declares
5
+ * `worker`, deployed from a built artifact that entry's own repo publishes
6
+ * (estate DECISIONS.md, 2026-10-04: foundry fetches and verifies it, never
7
+ * checks it out or bundles it). Unlike `planEntryAiKeys`/`planFilesKeys`,
8
+ * there is no renewal window -- a Worker script has no expiry -- so a step
9
+ * is planned again only while the entry's own declared config (`route`,
10
+ * `bindings` or, crucially, `artifactSha256`) differs from what was last
11
+ * applied (recorded as that config's JSON, `workerAppliedScopeKey`):
12
+ * `artifactSha256` is itself the declared signal that the built bytes
13
+ * changed, so a rebuild under an unchanged URL still redeploys as soon as
14
+ * the entry declares the new digest. "Blocked, not failing" (CLAUDE.md): a
15
+ * host whose executor carries no `WorkersPort` reports every entry's step
16
+ * (apply or retire) as blocked, not planned to a step that would throw.
17
+ */
18
+ export declare const entryWorkerAppliedScopeKey: (entryKey: string) => string;
19
+ export declare const entryWorkerRouteIdScopeKey: (entryKey: string) => string;
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 entryWorkerRoutePatternScopeKey: (entryKey: string) => string;
22
+ /**
23
+ * The script name retired, once a retire step completes. `runSteps` only
24
+ * ever writes a step's main recorded id from a *truthy* `resourceId`
25
+ * (`plan-apply.ts`), and retire returns none -- so the main
26
+ * `cloudflare:worker:<key>` id is never cleared by returning from the step,
27
+ * and without this marker the "gone" scan below would plan the same retire
28
+ * forever. Compared against that still-live id, the same convergence
29
+ * `entryAiRetiredScopeKey`/`entryFilesRetiredScopeKey`/
30
+ * `entryEdgeRetiredScopeKey` already give their own entry keys.
31
+ */
32
+ export declare const entryWorkerRetiredScopeKey: (entryKey: string) => string;
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 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 {
50
+ id: string;
51
+ apex: string;
52
+ }
53
+ export interface WorkerRoutePending extends WorkerRouteZone {
54
+ pattern: string;
55
+ script: string;
56
+ }
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). */
58
+ export declare const workerTraceScopeKeys: (entryKey: string) => string[];
59
+ /**
60
+ * Every entry key of `orgId` with something foundry deployed still live,
61
+ * mapped to the script name recorded for it (`''` if only a route trace is
62
+ * left). Read by the planner's gone-scan and by org destroy alike, so the two
63
+ * agree on what is live: the main `cloudflare:worker:<org>:<entry>` id
64
+ * (unless the retired marker matches it), or any partial-deploy trace -- the
65
+ * uploaded name, a recorded route id, or a pending route -- regardless of
66
+ * the retired marker, since an apply clears that marker before it leaves any
67
+ * of these (#318 re-review).
68
+ */
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;
72
+ /** Cloudflare's own limit on a Worker script name. */
73
+ export declare const WORKER_NAME_MAX_LENGTH = 63;
74
+ /**
75
+ * `${8-hex-char hash of org.id}-${entry.key}-edge`, not `${org.slug}-...`
76
+ * (#318 re-review): a slug and an entry key both allow hyphens, so org
77
+ * `a-b` entry `c` and org `a` entry `b-c` produced the identical name
78
+ * `a-b-c-edge` on the one shared Cloudflare account -- `scriptExists` only
79
+ * caught the second of the two to apply, and two concurrent applies could
80
+ * both pass it. `org.id` is a UUID with no ambiguity of its own, but using
81
+ * it in full would make the fixed part of the name longer than most of the
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`)
84
+ * checks the resulting name never exceeds that limit.
85
+ */
86
+ export declare const workerName: (org: DeclaredOrg, entry: HostingEntry) => string;
87
+ /**
88
+ * Foundry's own fixed compatibility date for every Worker it deploys -- not
89
+ * a per-entry declared value, so it is not part of {@link WorkerWant}
90
+ * and never triggers a re-plan on its own; bumping it is a foundry release,
91
+ * like {@link RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS} is a fixed Cloudflare
92
+ * Free plan value rather than something an org declares.
93
+ */
94
+ export declare const WORKER_COMPATIBILITY_DATE = "2026-09-12";
95
+ export type WorkerMode = 'apply' | 'retire';
96
+ export interface WorkerWant {
97
+ name: string;
98
+ artifactUrl: string;
99
+ artifactSha256: string;
100
+ route: string;
101
+ bindings: WorkerConfig['bindings'];
102
+ /** No-Worker route patterns, sorted; set only for a wildcard route with hostnames to keep out. */
103
+ exclusions?: string[];
104
+ }
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;
114
+ type Blocked = Array<{
115
+ description: string;
116
+ key: string;
117
+ }>;
118
+ /**
119
+ * One step per entry whose `worker` config is new or changed, plus one retire
120
+ * step per entry whose Worker is recorded but no longer declared -- either
121
+ * because the entry dropped `worker` or because the entry itself is gone, the
122
+ * same "gone" scan `planEntryAiKeys`/`planFilesKeys`/`planEntryEdgeKeys`
123
+ * run over their own recorded keys, so removing a whole entry still cleans
124
+ * up its Worker. `workersAvailable` is the host's own
125
+ * `FoundryPlanApplyOptions.edgeWorkers` (mirrors `jobsAvailable`): without
126
+ * it, every entry's step -- apply or retire -- is reported blocked rather
127
+ * than planned to fail at apply. The caller (`diff.ts`) gates the *apply*
128
+ * steps further, on an active zone, the same way it gates `planRateLimit`.
129
+ */
130
+ export declare function planWorkers(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, workersAvailable: boolean, platformHostnames?: readonly string[]): {
131
+ steps: DraftStep[];
132
+ blocked: Blocked;
133
+ };
134
+ export {};
@@ -0,0 +1,239 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { entryHostnames } from './hostnames.js';
3
+ import { lockedHostnames } from './locked-hostnames.js';
4
+ import { appStepKey } from './platform-diff.js';
5
+ /**
6
+ * F8 (#318): one plain Cloudflare Worker per hosting entry that declares
7
+ * `worker`, deployed from a built artifact that entry's own repo publishes
8
+ * (estate DECISIONS.md, 2026-10-04: foundry fetches and verifies it, never
9
+ * checks it out or bundles it). Unlike `planEntryAiKeys`/`planFilesKeys`,
10
+ * there is no renewal window -- a Worker script has no expiry -- so a step
11
+ * is planned again only while the entry's own declared config (`route`,
12
+ * `bindings` or, crucially, `artifactSha256`) differs from what was last
13
+ * applied (recorded as that config's JSON, `workerAppliedScopeKey`):
14
+ * `artifactSha256` is itself the declared signal that the built bytes
15
+ * changed, so a rebuild under an unchanged URL still redeploys as soon as
16
+ * the entry declares the new digest. "Blocked, not failing" (CLAUDE.md): a
17
+ * host whose executor carries no `WorkersPort` reports every entry's step
18
+ * (apply or retire) as blocked, not planned to a step that would throw.
19
+ */
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}`;
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. */
24
+ export const entryWorkerRoutePatternScopeKey = (entryKey) => `edge_worker_route_pattern_${entryKey}`;
25
+ /**
26
+ * The script name retired, once a retire step completes. `runSteps` only
27
+ * ever writes a step's main recorded id from a *truthy* `resourceId`
28
+ * (`plan-apply.ts`), and retire returns none -- so the main
29
+ * `cloudflare:worker:<key>` id is never cleared by returning from the step,
30
+ * and without this marker the "gone" scan below would plan the same retire
31
+ * forever. Compared against that still-live id, the same convergence
32
+ * `entryAiRetiredScopeKey`/`entryFilesRetiredScopeKey`/
33
+ * `entryEdgeRetiredScopeKey` already give their own entry keys.
34
+ */
35
+ export const entryWorkerRetiredScopeKey = (entryKey) => `edge_worker_retired_${entryKey}`;
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. */
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}`;
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). */
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),
57
+ ];
58
+ /**
59
+ * Every entry key of `orgId` with something foundry deployed still live,
60
+ * mapped to the script name recorded for it (`''` if only a route trace is
61
+ * left). Read by the planner's gone-scan and by org destroy alike, so the two
62
+ * agree on what is live: the main `cloudflare:worker:<org>:<entry>` id
63
+ * (unless the retired marker matches it), or any partial-deploy trace -- the
64
+ * uploaded name, a recorded route id, or a pending route -- regardless of
65
+ * the retired marker, since an apply clears that marker before it leaves any
66
+ * of these (#318 re-review).
67
+ */
68
+ export function liveWorkers(orgId, recordedIds) {
69
+ const mainPrefix = `cloudflare:worker:${orgId}:`;
70
+ const tracePrefixes = [
71
+ mainPrefix,
72
+ 'edge_worker_uploaded_',
73
+ 'edge_worker_route_id_',
74
+ 'edge_worker_route_pending_',
75
+ 'edge_worker_exclusions_',
76
+ ];
77
+ const entryKeys = new Set();
78
+ for (const [name, id] of Object.entries(recordedIds)) {
79
+ if (!id)
80
+ continue;
81
+ const prefix = tracePrefixes.find((p) => name.startsWith(p));
82
+ if (prefix)
83
+ entryKeys.add(name.slice(prefix.length));
84
+ }
85
+ const live = new Map();
86
+ for (const entryKey of entryKeys) {
87
+ const mainId = recordedIds[`${mainPrefix}${entryKey}`] ?? '';
88
+ const uploaded = recordedIds[entryWorkerUploadedScopeKey(entryKey)] ?? '';
89
+ const partial = uploaded ||
90
+ recordedIds[entryWorkerRouteIdScopeKey(entryKey)] ||
91
+ recordedIds[entryWorkerRoutePendingScopeKey(entryKey)] ||
92
+ recordedIds[entryWorkerExclusionsScopeKey(entryKey)];
93
+ const retired = mainId !== '' && recordedIds[entryWorkerRetiredScopeKey(entryKey)] === mainId;
94
+ if (!partial && (!mainId || retired))
95
+ continue;
96
+ live.set(entryKey, mainId || uploaded);
97
+ }
98
+ return live;
99
+ }
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;
102
+ /** Cloudflare's own limit on a Worker script name. */
103
+ export const WORKER_NAME_MAX_LENGTH = 63;
104
+ /**
105
+ * `${8-hex-char hash of org.id}-${entry.key}-edge`, not `${org.slug}-...`
106
+ * (#318 re-review): a slug and an entry key both allow hyphens, so org
107
+ * `a-b` entry `c` and org `a` entry `b-c` produced the identical name
108
+ * `a-b-c-edge` on the one shared Cloudflare account -- `scriptExists` only
109
+ * caught the second of the two to apply, and two concurrent applies could
110
+ * both pass it. `org.id` is a UUID with no ambiguity of its own, but using
111
+ * it in full would make the fixed part of the name longer than most of the
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`)
114
+ * checks the resulting name never exceeds that limit.
115
+ */
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`;
119
+ /**
120
+ * Foundry's own fixed compatibility date for every Worker it deploys -- not
121
+ * a per-entry declared value, so it is not part of {@link WorkerWant}
122
+ * and never triggers a re-plan on its own; bumping it is a foundry release,
123
+ * like {@link RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS} is a fixed Cloudflare
124
+ * Free plan value rather than something an org declares.
125
+ */
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);
152
+ return {
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,
159
+ };
160
+ }
161
+ /**
162
+ * One step per entry whose `worker` config is new or changed, plus one retire
163
+ * step per entry whose Worker is recorded but no longer declared -- either
164
+ * because the entry dropped `worker` or because the entry itself is gone, the
165
+ * same "gone" scan `planEntryAiKeys`/`planFilesKeys`/`planEntryEdgeKeys`
166
+ * run over their own recorded keys, so removing a whole entry still cleans
167
+ * up its Worker. `workersAvailable` is the host's own
168
+ * `FoundryPlanApplyOptions.edgeWorkers` (mirrors `jobsAvailable`): without
169
+ * it, every entry's step -- apply or retire -- is reported blocked rather
170
+ * than planned to fail at apply. The caller (`diff.ts`) gates the *apply*
171
+ * steps further, on an active zone, the same way it gates `planRateLimit`.
172
+ */
173
+ export function planWorkers(org, recordedIds, workersAvailable, platformHostnames = []) {
174
+ const steps = [];
175
+ const blocked = [];
176
+ // Hosting off declares no Worker at all: the gone-scan below still runs,
177
+ // so a Worker deployed while hosting was on is retired rather than left
178
+ // live forever (#318 re-review). Only planning a *new* deploy is skipped.
179
+ const declared = new Set(org.hosting.enabled ? org.hosting.entries.filter((e) => e.worker).map((e) => e.key) : []);
180
+ const mainPrefix = `cloudflare:worker:${org.id}:`;
181
+ // Every entryKey with a live, undeclared trace (`liveWorkers`): the
182
+ // main id or any partial-deploy marker, so a deploy that failed after
183
+ // upload or mid-route-create is still found to retire (#318 review).
184
+ for (const entryKey of liveWorkers(org.id, recordedIds).keys()) {
185
+ if (declared.has(entryKey))
186
+ continue;
187
+ const stepKey = `${org.id}:${entryKey}`;
188
+ if (!workersAvailable) {
189
+ blocked.push({
190
+ description: `${org.slug}'s ${entryKey} Worker is blocked, not planned: the host supplies no WorkersPort`,
191
+ key: `${mainPrefix}${entryKey}`,
192
+ });
193
+ continue;
194
+ }
195
+ steps.push({
196
+ action: { system: 'cloudflare', kind: 'worker', key: stepKey, desired: { mode: 'retire' } },
197
+ step: {
198
+ description: `delete ${org.slug}'s ${entryKey} Worker and its route: worker is no longer declared`,
199
+ destroys: true,
200
+ migrates: false,
201
+ },
202
+ });
203
+ }
204
+ if (!org.hosting.enabled)
205
+ return { steps, blocked };
206
+ for (const entry of org.hosting.entries) {
207
+ if (!entry.worker)
208
+ continue;
209
+ const stepKey = appStepKey(org, entry.key);
210
+ const key = `cloudflare:worker:${stepKey}`;
211
+ const at = `${org.slug}'s ${entry.key} Worker`;
212
+ if (!workersAvailable) {
213
+ blocked.push({
214
+ description: `${at} is blocked, not planned: the host supplies no WorkersPort`,
215
+ key,
216
+ });
217
+ continue;
218
+ }
219
+ const want = wantedWorker(org, entry, platformHostnames);
220
+ const wantJson = JSON.stringify(want);
221
+ const applied = recordedIds[entryWorkerAppliedScopeKey(entry.key)];
222
+ if (applied === wantJson)
223
+ continue; // Rerun: nothing changed, down to the built artifact's own digest.
224
+ steps.push({
225
+ action: {
226
+ system: 'cloudflare',
227
+ kind: 'worker',
228
+ key: stepKey,
229
+ desired: { mode: 'apply', compatibilityDate: WORKER_COMPATIBILITY_DATE, ...want },
230
+ },
231
+ step: {
232
+ description: `${applied ? 'update' : 'deploy'} ${at} on ${entry.worker.route}`,
233
+ destroys: false,
234
+ migrates: false,
235
+ },
236
+ });
237
+ }
238
+ return { steps, blocked };
239
+ }
@@ -12,8 +12,25 @@ export interface PlatformService {
12
12
  hostname: string;
13
13
  gitRef: string;
14
14
  }
15
- /** The eight shared services a declared platform names, decided in #165. */
16
- export type PlatformServiceKey = 'auth' | 'mail' | 'sfu' | 'files' | 'ai' | 'jobs' | 'integrations' | 'archon';
15
+ /**
16
+ * #491: `mail`'s own extra field, on top of every other service's shape.
17
+ * Company Included (or whichever org's mail domain is the platform's own
18
+ * apex) sends through the platform's existing Lettermint project rather than
19
+ * a second `<slug>-app` one of its own -- `undefined`/`null` until an
20
+ * operator declares which project that is. A ref only, never a value: the
21
+ * project's own sending token stays where it already lived, held under
22
+ * `PLATFORM_AUTH_BROKER_MAIL_KEY_REF` (`plan/platform-providers.ts`).
23
+ */
24
+ export interface PlatformMailService extends PlatformService {
25
+ lettermintProjectId?: string | null;
26
+ }
27
+ /**
28
+ * The shared services a declared platform names, decided in #165, plus
29
+ * `edge` (#317, F7): the service that mints an entry's own edge key and
30
+ * holds the Cloudflare token `<username>.<org domain>` claims use, so no
31
+ * hosting entry ever holds that credential itself.
32
+ */
33
+ export type PlatformServiceKey = 'auth' | 'mail' | 'sfu' | 'files' | 'ai' | 'jobs' | 'integrations' | 'archon' | 'edge';
17
34
  export declare const PLATFORM_SERVICE_KEYS: readonly PlatformServiceKey[];
18
35
  /**
19
36
  * The one shared platform every org's services run on -- decided in #165 as
@@ -42,13 +59,15 @@ export interface DeclaredPlatform {
42
59
  */
43
60
  serverIpv4?: string | null;
44
61
  auth: PlatformService;
45
- mail: PlatformService;
62
+ mail: PlatformMailService;
46
63
  sfu: PlatformService;
47
64
  files: PlatformService;
48
65
  ai: PlatformService;
49
66
  jobs: PlatformService;
50
67
  integrations: PlatformService;
51
68
  archon: PlatformService;
69
+ /** #317 (F7). No deploy step reads this service's own `gitRef` yet -- `N1` is deployed by hand until it exists. */
70
+ edge: PlatformService;
52
71
  }
53
72
  /** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
54
73
  export declare function defaultPlatformHostname(service: PlatformServiceKey, apex: string): string;
package/dist/platform.js CHANGED
@@ -9,6 +9,7 @@ export const PLATFORM_SERVICE_KEYS = [
9
9
  'jobs',
10
10
  'integrations',
11
11
  'archon',
12
+ 'edge',
12
13
  ];
13
14
  /** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
14
15
  export function defaultPlatformHostname(service, apex) {
@@ -47,5 +48,10 @@ export function validatePlatform(platform) {
47
48
  return `${key}.gitRef must not be empty once ${key}.enabled is true`;
48
49
  }
49
50
  }
51
+ // #491: declared, never discovered -- a present-but-blank id is refused the
52
+ // same way `serverIpv4`'s blank string is, rather than being read as unset.
53
+ if (platform.mail.lettermintProjectId != null && !platform.mail.lettermintProjectId.trim()) {
54
+ return 'mail.lettermintProjectId must not be empty when set';
55
+ }
50
56
  return null;
51
57
  }
@@ -578,7 +578,12 @@ function rowToPlatform(row) {
578
578
  apex: row.apex,
579
579
  ...(row.serverIpv4 ? { serverIpv4: row.serverIpv4 } : {}),
580
580
  auth: { enabled: row.authEnabled, hostname: row.authHostname, gitRef: row.authGitRef },
581
- mail: { enabled: row.mailEnabled, hostname: row.mailHostname, gitRef: row.mailGitRef },
581
+ mail: {
582
+ enabled: row.mailEnabled,
583
+ hostname: row.mailHostname,
584
+ gitRef: row.mailGitRef,
585
+ ...(row.mailLettermintProjectId ? { lettermintProjectId: row.mailLettermintProjectId } : {}),
586
+ },
582
587
  sfu: { enabled: row.sfuEnabled, hostname: row.sfuHostname, gitRef: row.sfuGitRef },
583
588
  files: { enabled: row.filesEnabled, hostname: row.filesHostname, gitRef: row.filesGitRef },
584
589
  ai: { enabled: row.aiEnabled, hostname: row.aiHostname, gitRef: row.aiGitRef },
@@ -593,6 +598,11 @@ function rowToPlatform(row) {
593
598
  hostname: row.archonHostname,
594
599
  gitRef: row.archonGitRef,
595
600
  },
601
+ edge: {
602
+ enabled: row.edgeEnabled,
603
+ hostname: row.edgeHostname,
604
+ gitRef: row.edgeGitRef,
605
+ },
596
606
  };
597
607
  }
598
608
  function platformToRow(platform) {
@@ -607,6 +617,7 @@ function platformToRow(platform) {
607
617
  mailEnabled: platform.mail.enabled,
608
618
  mailHostname: platform.mail.hostname,
609
619
  mailGitRef: platform.mail.gitRef,
620
+ mailLettermintProjectId: platform.mail.lettermintProjectId ?? null,
610
621
  sfuEnabled: platform.sfu.enabled,
611
622
  sfuHostname: platform.sfu.hostname,
612
623
  sfuGitRef: platform.sfu.gitRef,
@@ -625,6 +636,9 @@ function platformToRow(platform) {
625
636
  archonEnabled: platform.archon.enabled,
626
637
  archonHostname: platform.archon.hostname,
627
638
  archonGitRef: platform.archon.gitRef,
639
+ edgeEnabled: platform.edge.enabled,
640
+ edgeHostname: platform.edge.hostname,
641
+ edgeGitRef: platform.edge.gitRef,
628
642
  };
629
643
  }
630
644
  /**
@@ -1236,7 +1236,7 @@ export declare const foundryAuditEvents: import("drizzle-orm/pg-core").PgTableWi
1236
1236
  tableName: "foundry_audit_events";
1237
1237
  dataType: "string";
1238
1238
  columnType: "PgText";
1239
- data: "plan" | "destroy" | "apply";
1239
+ data: "apply" | "plan" | "destroy";
1240
1240
  driverParam: string;
1241
1241
  notNull: true;
1242
1242
  hasDefault: false;
@@ -1248,7 +1248,7 @@ export declare const foundryAuditEvents: import("drizzle-orm/pg-core").PgTableWi
1248
1248
  identity: undefined;
1249
1249
  generated: undefined;
1250
1250
  }, {}, {
1251
- $type: "plan" | "destroy" | "apply";
1251
+ $type: "apply" | "plan" | "destroy";
1252
1252
  }>;
1253
1253
  actorId: import("drizzle-orm/pg-core").PgColumn<{
1254
1254
  name: "actor_id";
@@ -1558,6 +1558,23 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
1558
1558
  identity: undefined;
1559
1559
  generated: undefined;
1560
1560
  }, {}, {}>;
1561
+ mailLettermintProjectId: import("drizzle-orm/pg-core").PgColumn<{
1562
+ name: "mail_lettermint_project_id";
1563
+ tableName: "foundry_declared_platform";
1564
+ dataType: "string";
1565
+ columnType: "PgText";
1566
+ data: string;
1567
+ driverParam: string;
1568
+ notNull: false;
1569
+ hasDefault: false;
1570
+ isPrimaryKey: false;
1571
+ isAutoincrement: false;
1572
+ hasRuntimeDefault: false;
1573
+ enumValues: [string, ...string[]];
1574
+ baseColumn: never;
1575
+ identity: undefined;
1576
+ generated: undefined;
1577
+ }, {}, {}>;
1561
1578
  sfuEnabled: import("drizzle-orm/pg-core").PgColumn<{
1562
1579
  name: "sfu_enabled";
1563
1580
  tableName: "foundry_declared_platform";
@@ -1864,6 +1881,57 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
1864
1881
  identity: undefined;
1865
1882
  generated: undefined;
1866
1883
  }, {}, {}>;
1884
+ edgeEnabled: import("drizzle-orm/pg-core").PgColumn<{
1885
+ name: "edge_enabled";
1886
+ tableName: "foundry_declared_platform";
1887
+ dataType: "boolean";
1888
+ columnType: "PgBoolean";
1889
+ data: boolean;
1890
+ driverParam: boolean;
1891
+ notNull: true;
1892
+ hasDefault: true;
1893
+ isPrimaryKey: false;
1894
+ isAutoincrement: false;
1895
+ hasRuntimeDefault: false;
1896
+ enumValues: undefined;
1897
+ baseColumn: never;
1898
+ identity: undefined;
1899
+ generated: undefined;
1900
+ }, {}, {}>;
1901
+ edgeHostname: import("drizzle-orm/pg-core").PgColumn<{
1902
+ name: "edge_hostname";
1903
+ tableName: "foundry_declared_platform";
1904
+ dataType: "string";
1905
+ columnType: "PgText";
1906
+ data: string;
1907
+ driverParam: string;
1908
+ notNull: true;
1909
+ hasDefault: false;
1910
+ isPrimaryKey: false;
1911
+ isAutoincrement: false;
1912
+ hasRuntimeDefault: false;
1913
+ enumValues: [string, ...string[]];
1914
+ baseColumn: never;
1915
+ identity: undefined;
1916
+ generated: undefined;
1917
+ }, {}, {}>;
1918
+ edgeGitRef: import("drizzle-orm/pg-core").PgColumn<{
1919
+ name: "edge_git_ref";
1920
+ tableName: "foundry_declared_platform";
1921
+ dataType: "string";
1922
+ columnType: "PgText";
1923
+ data: string;
1924
+ driverParam: string;
1925
+ notNull: true;
1926
+ hasDefault: true;
1927
+ isPrimaryKey: false;
1928
+ isAutoincrement: false;
1929
+ hasRuntimeDefault: false;
1930
+ enumValues: [string, ...string[]];
1931
+ baseColumn: never;
1932
+ identity: undefined;
1933
+ generated: undefined;
1934
+ }, {}, {}>;
1867
1935
  serviceIds: import("drizzle-orm/pg-core").PgColumn<{
1868
1936
  name: "service_ids";
1869
1937
  tableName: "foundry_declared_platform";
@@ -158,6 +158,8 @@ export const foundryDeclaredPlatform = pgTable('foundry_declared_platform', {
158
158
  mailEnabled: boolean('mail_enabled').notNull().default(false),
159
159
  mailHostname: text('mail_hostname').notNull(),
160
160
  mailGitRef: text('mail_git_ref').notNull().default(''),
161
+ /** #491, `migrations/0044_platform_mail_lettermint_project.sql`. Declared, never discovered. */
162
+ mailLettermintProjectId: text('mail_lettermint_project_id'),
161
163
  sfuEnabled: boolean('sfu_enabled').notNull().default(false),
162
164
  sfuHostname: text('sfu_hostname').notNull(),
163
165
  sfuGitRef: text('sfu_git_ref').notNull().default(''),
@@ -176,6 +178,10 @@ export const foundryDeclaredPlatform = pgTable('foundry_declared_platform', {
176
178
  archonEnabled: boolean('archon_enabled').notNull().default(false),
177
179
  archonHostname: text('archon_hostname').notNull(),
178
180
  archonGitRef: text('archon_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(''),
179
185
  /** `migrations/0018_platform_apply.sql`: the platform's applied state, `system:kind:key` to provider id. */
180
186
  serviceIds: jsonb('service_ids').$type().notNull().default({}),
181
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.0",
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",