@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
@@ -0,0 +1,260 @@
1
+ import { tunnelIngressHostnames } from './hostnames.js';
2
+ import { lockedHostnames } from './locked-hostnames.js';
3
+ import { appStepKey } from './platform-diff.js';
4
+ /**
5
+ * F7 (#317): one edge key per entry with `uses.edge`, minted directly
6
+ * below the org with the edge service (`EdgePort.mintKey({
7
+ * organisationId, ... })` -- no org-level root key the way files/ai have
8
+ * one, since the edge service takes the org id itself). `EDGE_URL` and
9
+ * `EDGE_API_KEY` go to the entry's env and nowhere else. A edge key has no
10
+ * declared renewal window in the issue, so unlike `planEntryAiKeys`/
11
+ * `planFilesKeys` this mints once and never renews.
12
+ *
13
+ * `EDGE_*` 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 edge key still bound to the old one. Not built --
23
+ * resolving it needs a manual revoke-and-re-mint (drop `uses.edge`, apply,
24
+ * re-add it) until this is.
25
+ */
26
+ export const entryEdgeUndeliveredScopeKey = (entryKey) => `edge_key_undelivered_${entryKey}`;
27
+ /** The id of a key that was live when its entry stopped declaring `uses.edge`, once handled. */
28
+ export const entryEdgeRetiredScopeKey = (entryKey) => `edge_key_retired_${entryKey}`;
29
+ export const ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS = 3;
30
+ /** How long an entry's edge key lives. The issue names no renewal window, so this mints once and never renews. */
31
+ export const EDGE_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 const entryEdgeMintedAtScopeKey = (entryKey) => `edge_key_minted_at_${entryKey}`;
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 const EDGE_ENTRY_KEY_WARN_WINDOW_DAYS = 30;
36
+ const DAY_MS = 24 * 60 * 60 * 1000;
37
+ /** The reserved labels last sent to the edge service, as that sorted list's own JSON. */
38
+ export const EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY = 'edge_reserved_labels_applied';
39
+ export const entryEdgeKeyName = (org, entry) => `${org.slug}-${entry.key}-edge`;
40
+ /**
41
+ * Mail names foundry's mail setup puts on the org's domain. Stalwart's and
42
+ * Lettermint's own record names (DKIM selectors, return path) come back from
43
+ * the mail port at apply time, so only the fixed ones are listed here.
44
+ */
45
+ const MAIL_LABELS = ['mail', 'autoconfig', 'autodiscover', '_dmarc'];
46
+ /**
47
+ * Every label under `org.domain` that foundry provisions or the platform
48
+ * uses, so edge never lets a company claim one: the hostnames foundry routes
49
+ * (`tunnelIngressHostnames`: entries, site, cms, calls, Agora, Boule, share),
50
+ * the names it locks (`lockedHostnames`: also the sign-in host and the
51
+ * platform's service hostnames), each entry's own labels, the mail labels
52
+ * and `www`. The entry's `*` is the space users claim in, so it reserves
53
+ * nothing, and neither does the bare domain.
54
+ *
55
+ * An org without a domain has no hostnames edge sees (`uses.edge` needs a
56
+ * domain), so it reserves `www` only.
57
+ */
58
+ export function edgeReservedLabels(org, platformHostnames = []) {
59
+ const labels = new Set(['www']);
60
+ const { domain } = org;
61
+ if (!domain)
62
+ return [...labels];
63
+ for (const label of MAIL_LABELS)
64
+ labels.add(label);
65
+ for (const entry of org.hosting.entries) {
66
+ for (const name of entry.hostnames) {
67
+ if (name !== '@' && name !== '*')
68
+ labels.add(name);
69
+ }
70
+ }
71
+ const suffix = `.${domain}`;
72
+ for (const host of [...tunnelIngressHostnames(org), ...lockedHostnames(org, platformHostnames)]) {
73
+ if (host.endsWith(suffix) && !host.startsWith('*.'))
74
+ labels.add(host.slice(0, -suffix.length));
75
+ }
76
+ return [...labels].sort();
77
+ }
78
+ /**
79
+ * One step per entry whose edge key is missing, plus one retire step per
80
+ * entry whose `uses.edge` was removed but still has a recorded key.
81
+ * Blocked, not planned (rule 2), while the platform has no edge service
82
+ * wired, the entry's app is not recorded, or a previous mint's delivery
83
+ * failed.
84
+ */
85
+ export function planEntryEdgeKeys(org, recordedIds, plannedSteps, readiness, now = Date.now()) {
86
+ const steps = [];
87
+ const blocked = [];
88
+ const planned = (system, kind, k) => plannedSteps.some((p) => p.action.system === system && p.action.kind === kind && p.action.key === k);
89
+ // A key whose entry lost `uses.edge`, or whose entry is gone entirely --
90
+ // either way it is no longer in `declared`, the same "gone" scan
91
+ // `planEntryAiKeys`/`planFilesKeys` run over their own recorded keys.
92
+ // Gated on `edgeReady`: without a port there is nothing that could revoke
93
+ // it, so it is reported blocked, not planned to a step that would throw.
94
+ //
95
+ // Scanned from both the main recorded id and the undelivered marker: a
96
+ // key minted but never delivered never gets a main id (the mint step
97
+ // throws before `runSteps` sees a `resourceId`), so scanning the main
98
+ // prefix alone would never find it to revoke once its entry is gone
99
+ // (#317 review).
100
+ const declared = new Set(org.hosting.entries.filter((e) => e.uses?.edge).map((e) => e.key));
101
+ const mainPrefix = `edge:key:${org.id}:`;
102
+ const undeliveredPrefix = 'edge_key_undelivered_';
103
+ const goneEntryKeys = new Set();
104
+ for (const [name, id] of Object.entries(recordedIds)) {
105
+ if (!id)
106
+ continue;
107
+ let entryKey;
108
+ if (name.startsWith(mainPrefix))
109
+ entryKey = name.slice(mainPrefix.length);
110
+ else if (name.startsWith(undeliveredPrefix))
111
+ entryKey = name.slice(undeliveredPrefix.length);
112
+ if (entryKey === undefined || declared.has(entryKey))
113
+ continue;
114
+ // Only the main id is subject to the retired-marker convergence check
115
+ // -- it's the one `runSteps` never clears on its own. An undelivered
116
+ // id is cleared directly on retire (no marker needed), so its mere
117
+ // presence here means it still needs revoking, regardless of what a
118
+ // stale, already-retired main id says: a key minted after a retire
119
+ // (mint, three failed deliveries, undeclare again) must not hide
120
+ // behind id A's retired marker just because id A's main-id field is
121
+ // still truthy (#317 re-review).
122
+ if (name.startsWith(mainPrefix) && recordedIds[entryEdgeRetiredScopeKey(entryKey)] === id) {
123
+ continue;
124
+ }
125
+ goneEntryKeys.add(entryKey);
126
+ }
127
+ for (const entryKey of goneEntryKeys) {
128
+ if (!readiness.edgeReady) {
129
+ blocked.push({
130
+ description: `${org.slug}'s ${entryKey} edge key is blocked, not planned: the host supplies no edge service, or the platform has not enabled it`,
131
+ key: `${mainPrefix}${entryKey}`,
132
+ });
133
+ continue;
134
+ }
135
+ steps.push({
136
+ action: {
137
+ system: 'edge',
138
+ kind: 'key',
139
+ key: `${org.id}:${entryKey}`,
140
+ desired: { mode: 'retire' },
141
+ },
142
+ step: {
143
+ description: `revoke ${org.slug}'s ${entryKey} edge key: the entry no longer declares uses.edge`,
144
+ destroys: true,
145
+ migrates: false,
146
+ },
147
+ });
148
+ }
149
+ for (const entry of org.hosting.entries) {
150
+ if (!entry.uses?.edge)
151
+ continue;
152
+ const stepKey = appStepKey(org, entry.key);
153
+ const key = `edge:key:${stepKey}`;
154
+ const liveKeyId = recordedIds[key] && recordedIds[entryEdgeRetiredScopeKey(entry.key)] !== recordedIds[key]
155
+ ? recordedIds[key]
156
+ : undefined;
157
+ if (liveKeyId) {
158
+ // No renewal: minted once. Still surfaced once it is close to or past
159
+ // its silent expiry -- there is no step to plan, since nothing here
160
+ // renews it, but a plan that says nothing about a key about to stop
161
+ // working is the gap this is for (#317 review). Prefixed `warning:`,
162
+ // the same convention `planBrokerEntries`'s invalid-AUTH_APPS notice
163
+ // uses, so a caller reading this list for real blockers (one a human
164
+ // mint could ever clear) can tell the two apart -- a permanently
165
+ // "blocked" row past day 335 that no action removes is the problem
166
+ // this prefix exists to avoid.
167
+ const mintedAt = recordedIds[entryEdgeMintedAtScopeKey(entry.key)];
168
+ if (mintedAt) {
169
+ const expiresAt = Date.parse(mintedAt) + EDGE_ENTRY_KEY_DAYS * DAY_MS;
170
+ const daysLeft = Math.floor((expiresAt - now) / DAY_MS);
171
+ if (daysLeft < EDGE_ENTRY_KEY_WARN_WINDOW_DAYS) {
172
+ blocked.push({
173
+ description: `warning: ${org.slug}'s ${entry.key} edge key ${daysLeft < 0 ? 'expired' : 'expires'} ${new Date(expiresAt).toISOString().slice(0, 10)} and is never renewed (#317): mint a new key by hand and update the entry's env`,
174
+ key,
175
+ });
176
+ }
177
+ }
178
+ continue;
179
+ }
180
+ const at = `${org.slug}'s ${entry.key} edge key`;
181
+ const block = (why) => blocked.push({ description: `${at} is blocked: ${why}`, key });
182
+ // An undelivered key from a previous mint is no longer a reason to
183
+ // block forever: nothing here keeps the secret around to retry
184
+ // delivering it, so a mint step is still planned, and the executor
185
+ // itself revokes the stale id first before minting a fresh one (#317
186
+ // re-review) -- blocking indefinitely, as a prior round did, left the
187
+ // orphan live for up to 365 days with no recorded trace an operator
188
+ // could act on beyond this description.
189
+ if (!readiness.edgeReady) {
190
+ block('the host supplies no edge service, or the platform has not enabled it');
191
+ continue;
192
+ }
193
+ if (!recordedIds[`coolify:app:${stepKey}`] && !planned('coolify', 'app', stepKey)) {
194
+ block("the entry's app is not recorded yet");
195
+ continue;
196
+ }
197
+ steps.push({
198
+ action: {
199
+ system: 'edge',
200
+ kind: 'key',
201
+ key: stepKey,
202
+ desired: {
203
+ mode: 'mint',
204
+ name: entryEdgeKeyName(org, entry),
205
+ edgeUrl: readiness.edgeUrl,
206
+ },
207
+ },
208
+ step: {
209
+ description: `mint ${at} below the org, write EDGE_* env, restart the app`,
210
+ destroys: false,
211
+ migrates: false,
212
+ },
213
+ });
214
+ }
215
+ return { steps, blocked };
216
+ }
217
+ /**
218
+ * The reserved-labels step: resent whenever the org's own label set
219
+ * (`edgeReservedLabels`) differs from what was last sent. While the org
220
+ * uses edge at all (at least one entry with `uses.edge`) that set is
221
+ * `edgeReservedLabels(org)`; once the last entry drops `uses.edge`, this
222
+ * clears whatever was last sent (an empty list) rather than leaving it
223
+ * stuck in names forever -- a one-time step, same as any other change.
224
+ */
225
+ export function planEdgeReservedLabels(org, recordedIds, readiness, platformHostnames = []) {
226
+ const usesEdge = org.hosting.entries.some((e) => e.uses?.edge);
227
+ const applied = recordedIds[EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY];
228
+ if (!usesEdge && (!applied || applied === '[]')) {
229
+ return { step: null, blocked: [] };
230
+ }
231
+ if (!readiness.edgeReady) {
232
+ return {
233
+ step: null,
234
+ blocked: [
235
+ {
236
+ description: `${org.slug}'s reserved labels are blocked: the host supplies no edge service, or the platform has not enabled it`,
237
+ key: `edge:reserved-labels:${org.id}`,
238
+ },
239
+ ],
240
+ };
241
+ }
242
+ const labels = usesEdge ? edgeReservedLabels(org, platformHostnames) : [];
243
+ const wantJson = JSON.stringify(labels);
244
+ if (applied === wantJson) {
245
+ return { step: null, blocked: [] };
246
+ }
247
+ return {
248
+ step: {
249
+ action: { system: 'edge', kind: 'reserved-labels', key: org.id, desired: { labels } },
250
+ step: {
251
+ description: usesEdge
252
+ ? `send ${org.slug}'s reserved labels to edge: ${labels.join(', ')}`
253
+ : `clear ${org.slug}'s reserved labels from edge: no entry uses edge any more`,
254
+ destroys: false,
255
+ migrates: false,
256
+ },
257
+ },
258
+ blocked: [],
259
+ };
260
+ }
@@ -2,7 +2,7 @@ import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/p
2
2
  import type { DeclaredOrg, HostingBuildPack } from '../org.js';
3
3
  import type { DeclaredOrgStore } from '../store.js';
4
4
  import { type BrokerPort } from './broker-entries.js';
5
- import type { StepAction } from './diff.js';
5
+ import { type StepAction } from './diff.js';
6
6
  import { type LoginHostBindingsPort } from './login-host.js';
7
7
  import { type MailCopyPort } from './mail-copy.js';
8
8
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
@@ -80,11 +80,25 @@ export interface CoolifyPort {
80
80
  * `findOrCreateCmsApp`'s own check of whether an app it found by name
81
81
  * (rather than just created) needs starting again (#116 review round 3,
82
82
  * Finding 1).
83
+ *
84
+ * #480: also the rolling-update/health-check settings `updateApplication`
85
+ * patches on `platform:archon` (`is_consistent_container_name_enabled`,
86
+ * `custom_network_aliases`, `health_check_enabled`), so a drift check can
87
+ * tell a setting changed back by hand from one this run already applied.
88
+ * `is_consistent_container_name_enabled` comes back nested under
89
+ * `settings`, not at the top level -- confirmed against live Coolify
90
+ * 4.3.23 (PR #482 review); `custom_network_aliases` and
91
+ * `health_check_enabled` are both top-level there.
83
92
  */
84
93
  getApplication(uuid: string): Promise<{
85
94
  uuid: string;
86
95
  status?: string;
87
96
  custom_labels?: string | null;
97
+ settings?: {
98
+ is_consistent_container_name_enabled?: boolean;
99
+ };
100
+ custom_network_aliases?: string | null;
101
+ health_check_enabled?: boolean;
88
102
  }>;
89
103
  createGithubAppApplication(input: {
90
104
  project_uuid: string;
@@ -535,6 +549,75 @@ export interface CloudflarePort {
535
549
  */
536
550
  request<T>(method: string, path: string, body?: unknown, missingOkay?: boolean): Promise<T>;
537
551
  }
552
+ /**
553
+ * One Cloudflare Workers rate limiting binding, Cloudflare's own "simple"
554
+ * shape -- the metadata part {@link WorkersPort.uploadScript} sends beside
555
+ * the module (#318).
556
+ */
557
+ export interface WorkersRateLimitBinding {
558
+ type: 'ratelimit';
559
+ name: string;
560
+ namespace_id: string;
561
+ simple: {
562
+ limit: number;
563
+ period: number;
564
+ };
565
+ }
566
+ /**
567
+ * The Cloudflare Workers calls `cloudflareWorker` makes (#318, F8): deploys
568
+ * a plain Worker from an entry's `worker` declaration and routes it on the
569
+ * org's own zone. Optional on {@link FoundryStepExecutorPorts} -- a host
570
+ * with no Workers rights on its Cloudflare token (the issue's own "Human
571
+ * steps": the token needs them added first) wires none, and the planner
572
+ * reports every `worker` entry blocked instead of planning a step that would
573
+ * throw (`FoundryPlanApplyOptions.edgeWorkers`).
574
+ *
575
+ * `uploadScript` takes the *declared artifact reference*, not bytes:
576
+ * fetching `artifactUrl` and verifying it against `artifactSha256` happens
577
+ * behind this port, never in `cloudflareWorker` or anywhere else in this
578
+ * package (estate DECISIONS.md, 2026-10-04, "offline.tf Worker setup" --
579
+ * "Foundry consumes and deploys the artifact through the provider
580
+ * boundary... No manual build step and no source checkout/bundling in
581
+ * Foundry"). The real adapter refuses a fetched body whose digest does not
582
+ * match. The upload itself is a multipart request Cloudflare's API wants (a
583
+ * `metadata` part naming `main_module` and `bindings`, plus the module) --
584
+ * `CloudflarePort.request` sends JSON only, so this port names the call
585
+ * directly rather than forcing it through that escape hatch.
586
+ *
587
+ * `deleteRoute`/`deleteScript` are idempotent: deleting a route or script
588
+ * that is already gone resolves rather than throwing, the same contract
589
+ * `CloudflarePort.deleteDnsRecord`/`deleteAccessApp` give their own
590
+ * already-gone case via `isNotFoundError` at the call site -- here it is
591
+ * the port's own job, since `cloudflareWorker` deletes a route or script by
592
+ * id/name it already recorded, with nothing live to re-check first.
593
+ */
594
+ export interface WorkersPort {
595
+ /** Whether a script named `name` already exists -- checked only on an entry's first-ever apply, so `cloudflareWorker` can refuse to overwrite a script it did not create instead of PUTting over it by its deterministic name. */
596
+ scriptExists(name: string): Promise<boolean>;
597
+ uploadScript(input: {
598
+ name: string;
599
+ artifactUrl: string;
600
+ artifactSha256: string;
601
+ compatibilityDate: string;
602
+ bindings?: WorkersRateLimitBinding[];
603
+ }): Promise<void>;
604
+ /** The script name currently bound to `pattern` in `zoneId`, or `undefined` for no route there. */
605
+ findRoute(zoneId: string, pattern: string): Promise<{
606
+ id: string;
607
+ script?: string;
608
+ } | undefined>;
609
+ /**
610
+ * Creates the route if absent; `cloudflareWorker` only calls this once `findRoute` has confirmed the pattern is foundry's own or unclaimed.
611
+ * With no `script` it creates Cloudflare's "no Worker" route (`POST /zones/{zone_id}/workers/routes` with `pattern` only), which turns Workers off for that pattern; a more specific pattern wins over a wildcard.
612
+ */
613
+ createRoute(zoneId: string, pattern: string, script?: string): Promise<{
614
+ id: string;
615
+ }>;
616
+ /** A no-op, not a throw, when `routeId` is already gone. */
617
+ deleteRoute(zoneId: string, routeId: string): Promise<void>;
618
+ /** A no-op, not a throw, when `name` is already gone. */
619
+ deleteScript(name: string): Promise<void>;
620
+ }
538
621
  /**
539
622
  * One DNS record a Lettermint domain's own response says must be published --
540
623
  * `purpose` is Lettermint's own label (`return_path`, `dmarc`, `dkim_legacy`,
@@ -1257,6 +1340,37 @@ export interface AiOrganisationPort {
1257
1340
  limitMicros: string;
1258
1341
  }): Promise<unknown>;
1259
1342
  }
1343
+ /**
1344
+ * #317 (F7): the edge service's own management API
1345
+ * (https://github.com/wtfalch/edge, live on main, offering `mintKey`,
1346
+ * `revokeKey` and `setReservedLabels`) -- this port states the shape this
1347
+ * executor assumes of it. `organisationId` is the org itself: unlike
1348
+ * `AiOrganisationPort.mintNestedKey`, edge takes no separate minter key
1349
+ * id, so `edgeEntryKey` mints directly below the org. A real adapter
1350
+ * wiring this port into `platform-providers.ts` is tracked separately
1351
+ * (https://github.com/wtfalch/archon/issues/339).
1352
+ */
1353
+ export interface EdgePort {
1354
+ /**
1355
+ * `domain` is required: edge's own `mintKey` refuses a request without a
1356
+ * lowercase hostname and binds the key to it
1357
+ * (`wtfalch/edge`'s `packages/service/src/keys.ts`) -- `edgeEntryKey`
1358
+ * passes `org.domain`, which `validateEntryEdge` already requires before
1359
+ * `uses.edge` is accepted.
1360
+ */
1361
+ mintKey(input: {
1362
+ organisationId: string;
1363
+ domain: string;
1364
+ name: string;
1365
+ expiresDays: number;
1366
+ }): Promise<{
1367
+ id: string;
1368
+ secret: string;
1369
+ }>;
1370
+ revokeKey(organisationId: string, keyId: string): Promise<void>;
1371
+ /** Refuses a label any hosting entry of `organisationId` already holds. Rerun whenever the org's own label set changes. */
1372
+ setReservedLabels(organisationId: string, labels: string[]): Promise<void>;
1373
+ }
1260
1374
  /**
1261
1375
  * #173: the jobs scheduler's own admin API
1262
1376
  * (https://github.com/wtfalch/jobs, "The admin API", jobs#62), authenticated
@@ -1325,6 +1439,15 @@ export interface FoundryStepExecutorPorts {
1325
1439
  /** Flat, not a factory: `organisationId` is an explicit argument on every method of both ports (see each port's own doc comment; #177 dropped the `filesLimitsFor`/`aiLimitsFor` factories this used to need). */
1326
1440
  filesOrganisation?: FilesOrganisationPort;
1327
1441
  aiOrganisation?: AiOrganisationPort;
1442
+ /**
1443
+ * #317 (F7): mints an entry's edge key -- see {@link EdgePort}'s own
1444
+ * doc comment. Optional the same way `jobsScheduler` is: a host with no
1445
+ * edge service deployed builds the executor without one, and tells the
1446
+ * planner so (`FoundryPlanApplyOptions.edge`), which then reports each
1447
+ * entry's `uses.edge` step as blocked instead of planning one that would
1448
+ * throw.
1449
+ */
1450
+ edge?: EdgePort;
1328
1451
  /**
1329
1452
  * #177: builds the files, AI and jobs ports from the platform's addresses
1330
1453
  * and held keys. A port set above overrides it (tests); with neither, the
@@ -1347,6 +1470,13 @@ export interface FoundryStepExecutorPorts {
1347
1470
  * `FoundryPlanApplyOptions.broker`).
1348
1471
  */
1349
1472
  broker?: BrokerPort;
1473
+ /**
1474
+ * #318: deploys and routes an entry's `worker` Worker -- see
1475
+ * {@link WorkersPort}'s own doc comment. Optional the same way `broker` is:
1476
+ * without it the plan reports each `worker` entry blocked (the host also
1477
+ * sets `FoundryPlanApplyOptions.edgeWorkers`).
1478
+ */
1479
+ workers?: WorkersPort;
1350
1480
  /**
1351
1481
  * Hands a just-minted, one-time secret (today: an API app's
1352
1482
  * `clientSecret`) to the host, immediately after the provider returns it
@@ -1490,6 +1620,57 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1490
1620
  * holds the hostname.
1491
1621
  */
1492
1622
  private cloudflareRateLimit;
1623
+ /**
1624
+ * `plan/diff.ts`'s `planWorkers` (#318, F8): deploys one entry's
1625
+ * `worker` Worker and routes it on the org's own zone. Throws by name
1626
+ * without a {@link WorkersPort}, the same `jobsRegistration` shape --
1627
+ * `planWorkers` reports the step blocked instead while the host has
1628
+ * none, so this only fires against a stale plan. The route is checked
1629
+ * before upload: a foreign route already on the pattern is refused, never
1630
+ * adopted or replaced (`cloudflareAccess`'s own precedent for a foreign
1631
+ * resource on the same name), so a Worker is never deployed only to find
1632
+ * nothing can route to it.
1633
+ *
1634
+ * Order on apply: record (and persist) the uploaded name and clear the
1635
+ * retired marker, upload, record the pending route, create the route only
1636
+ * if needed, delete any *previous* route this entry recorded on its own
1637
+ * zone, then record the new route id/zone/pattern and the applied
1638
+ * declaration last -- every crash point leaves a recorded trace that
1639
+ * retire and destroy can clean up (#318 re-review), and per CLAUDE.md's
1640
+ * "Declared changes apply" nothing is marked done until the
1641
+ * live state actually matches it.
1642
+ */
1643
+ private cloudflareWorker;
1644
+ /**
1645
+ * Makes the no-Worker routes recorded for an entry match `patterns`: deletes
1646
+ * the ones no longer wanted (all of them if the zone changed), creates the
1647
+ * missing ones without a script. A route already on a pattern that is bound
1648
+ * to a Worker is foreign and refused; one with no script is adopted. Records
1649
+ * and persists after each change.
1650
+ */
1651
+ private syncWorkerExclusions;
1652
+ /**
1653
+ * The zone id a recorded route lives in, or `undefined` only when that
1654
+ * zone is *positively* gone -- its apex resolves to no zone, or to a
1655
+ * different zone than the one recorded (#318 re-review). Reads the zone
1656
+ * recorded beside the route, never `org.domain`: the route id and its
1657
+ * zone are always recorded together (the apply branch below writes both
1658
+ * in the same batch, and the pending-route marker carries its own zone
1659
+ * too), so a route id with no zone recorded cannot happen within this
1660
+ * feature -- and re-resolving from `org.domain` as a fallback would risk
1661
+ * looking up the *new* zone after a domain change and leaving the route
1662
+ * live on the old one, the exact bug a recorded-per-route zone exists to
1663
+ * avoid (#318 re-review).
1664
+ */
1665
+ private liveWorkerZone;
1666
+ /** Deletes the route an interrupted apply was creating, if `findRoute` shows it exists, points at that apply's script, and is not `keepRouteId`. */
1667
+ private deletePendingWorkerRoute;
1668
+ /**
1669
+ * Retire and destroy share this: delete the recorded route on its own
1670
+ * zone, then any route an interrupted apply created but never recorded,
1671
+ * then the script. Records nothing -- the caller does.
1672
+ */
1673
+ private teardownWorker;
1493
1674
  private zitadelIssuer;
1494
1675
  /**
1495
1676
  * `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
@@ -1761,6 +1942,22 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1761
1942
  * comment), never logged, never interpolated into a thrown error -- the
1762
1943
  * same discipline `pushDatabaseUrl`'s doc comment states for a database's
1763
1944
  * own connection string.
1945
+ *
1946
+ * #491: none of the above applies to the org whose mail domain is the
1947
+ * platform's own apex -- it shares the platform's existing Lettermint
1948
+ * project (`platformLettermintProjectId`, from `DeclaredPlatform.mail`)
1949
+ * instead of minting a `<slug>-app` one, and this method never creates or
1950
+ * deletes a project for it. `platformApex`/`platformLettermintProjectId`
1951
+ * come straight off this step's own `desired`, the same "the executor
1952
+ * writes exactly what the plan showed" rule `coolifyMail` already follows
1953
+ * for its own `apex`/`mailHost` -- the caller (`coolifyApp`) refuses a
1954
+ * `desired` missing the `platformApex` key outright as a stale plan --
1955
+ * checked here, after the no-op-until-a-mail-domain-exists return just
1956
+ * below, rather than unconditionally at the call site: every other
1957
+ * `coolify:app` concern (hostnames, ports, settings, the sign-in env...)
1958
+ * has nothing to do with Lettermint and must keep working the moment a
1959
+ * mail domain was never even recorded for this org, the same as before
1960
+ * this field existed (review round 2, finding 3).
1764
1961
  */
1765
1962
  private ensureAppLettermintMail;
1766
1963
  /**
@@ -1796,6 +1993,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1796
1993
  * it the same way the app step does, rather than assuming it already
1797
1994
  * exists -- the domain must never end up unrestricted, whichever step gets
1798
1995
  * there first.
1996
+ *
1997
+ * #491: this org's own `<slug>-relay` project is unaffected by whether it
1998
+ * is the platform's own org -- only the domain restriction
1999
+ * (`platformApex`/`platformLettermintProjectId`, forwarded to
2000
+ * {@link ensureLettermintDomainRestriction}) and the delete loop below
2001
+ * (which must never delete the platform's own project) change.
1799
2002
  */
1800
2003
  private ensureOrgLettermintRelay;
1801
2004
  /**
@@ -1818,6 +2021,21 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1818
2021
  * unrestricted domain cannot verify and cannot send, whichever project
1819
2022
  * owns it (post-#140 review finding, restated here rather than only at
1820
2023
  * the one call site it used to have).
2024
+ *
2025
+ * #491: `platformApex`/`platformLettermintProjectId` cover both callers at
2026
+ * once -- when `mailDomain` is the platform's own apex, the platform's
2027
+ * project id is always folded into the restriction, so a plan that shares
2028
+ * the platform's project (rather than minting a `<slug>-app` one) never
2029
+ * ends up restricting that org's domain to nothing. A no-op for every
2030
+ * other org's domain, since `mailDomain` never matches `platformApex` there.
2031
+ * Review round 2, finding 4: a declared id that is not among `allProjects`
2032
+ * (a mistype, or the project was deleted in Lettermint) is refused rather
2033
+ * than folded in anyway -- that would restrict the apex to whatever *is*
2034
+ * live (the relay project, say) plus an id naming nothing, which excludes
2035
+ * the real platform project from its own domain exactly as completely as
2036
+ * never restricting it at all. Centralised here rather than duplicated at
2037
+ * each caller, since both {@link ensureAppLettermintMail} and
2038
+ * {@link ensureOrgLettermintRelay} reach the apex through this one method.
1821
2039
  */
1822
2040
  private ensureLettermintDomainRestriction;
1823
2041
  /**
@@ -2356,6 +2574,13 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2356
2574
  * domain recorded) and on a same-domain re-apply (compared
2357
2575
  * case-insensitively, the same rule {@link findExactLettermintDomain}
2358
2576
  * applies to the lookup itself).
2577
+ *
2578
+ * #491 review: also a no-op when the previous domain is the platform's own
2579
+ * apex -- an org that moves its mail *off* that domain (`usesDomain` flips,
2580
+ * or `org.domain` changes) must never delete it here, the same protection
2581
+ * {@link parkLettermintMail} already gives the apex on a move to `off`.
2582
+ * `planMail` only blocks a move *onto* the apex (no project id yet); a move
2583
+ * away from it reaches this method with nothing to block it otherwise.
2359
2584
  */
2360
2585
  private deleteStaleLettermintDomain;
2361
2586
  /**
@@ -2398,6 +2623,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2398
2623
  * A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
2399
2624
  * nothing was ever provisioned for an org whose mail went straight from
2400
2625
  * never-applied to off.
2626
+ *
2627
+ * #491: when `mailDomain` is the platform's own apex, this must never
2628
+ * delete the platform's own project (the delete loop skips it by id) or
2629
+ * the platform's own domain (returns before the domain lookup/delete).
2401
2630
  */
2402
2631
  private parkLettermintMail;
2403
2632
  /**
@@ -2497,6 +2726,22 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2497
2726
  * new key.
2498
2727
  */
2499
2728
  private revokeEntryKey;
2729
+ /**
2730
+ * F7 (#317): mints one entry's edge key directly below the org (edge
2731
+ * takes no separate minter key id, unlike ai/files), writes `EDGE_URL`
2732
+ * and `EDGE_API_KEY` to the entry's app env, then restarts it. Retires
2733
+ * the key when `uses.edge` is removed. Follows the "one mint" rule
2734
+ * (CLAUDE.md "Platform steps"): the id is recorded as undelivered and
2735
+ * persisted before delivery is attempted, so a crash or a failed write
2736
+ * never leads to a second mint -- `planEntryEdgeKeys` blocks instead.
2737
+ */
2738
+ private edgeEntryKey;
2739
+ /**
2740
+ * F7 (#317): `plan/entry-edge-key.ts`'s `planEdgeReservedLabels` --
2741
+ * tells the edge service every label this org's own entries already
2742
+ * hold, so edge refuses a customer claiming one of them.
2743
+ */
2744
+ private edgeReservedLabels;
2500
2745
  /**
2501
2746
  * F6b: mints or renews the org's own files key, or revokes the ones a
2502
2747
  * renewal replaced. The key is minted below the files organisation's owner