@wtfalch/foundry 0.17.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/bootstrap/declare.d.ts +11 -2
  2. package/dist/bootstrap/declare.js +20 -11
  3. package/dist/index.d.ts +4 -2
  4. package/dist/index.js +2 -1
  5. package/dist/migrations/0045_names_to_edge.sql +107 -0
  6. package/dist/org.d.ts +17 -17
  7. package/dist/org.js +48 -58
  8. package/dist/plan/agora.d.ts +25 -0
  9. package/dist/plan/agora.js +25 -0
  10. package/dist/plan/boule.d.ts +8 -0
  11. package/dist/plan/boule.js +8 -0
  12. package/dist/plan/diff.d.ts +3 -1
  13. package/dist/plan/diff.js +115 -52
  14. package/dist/plan/entry-edge-key.d.ts +79 -0
  15. package/dist/plan/{entry-names-key.js → entry-edge-key.js} +90 -66
  16. package/dist/plan/executor.d.ts +87 -42
  17. package/dist/plan/executor.js +277 -120
  18. package/dist/plan/index.d.ts +2 -2
  19. package/dist/plan/index.js +1 -1
  20. package/dist/plan/live.d.ts +3 -3
  21. package/dist/plan/login-host.d.ts +15 -11
  22. package/dist/plan/login-host.js +16 -16
  23. package/dist/plan/plan-apply.d.ts +6 -5
  24. package/dist/plan/plan-apply.js +40 -38
  25. package/dist/plan/platform-diff.d.ts +22 -22
  26. package/dist/plan/platform-diff.js +45 -45
  27. package/dist/plan/platform-org-ports.d.ts +1 -0
  28. package/dist/plan/platform-org-ports.js +3 -0
  29. package/dist/plan/platform-org.d.ts +8 -8
  30. package/dist/plan/platform-org.js +7 -7
  31. package/dist/plan/platform-providers.d.ts +17 -16
  32. package/dist/plan/platform-providers.js +136 -118
  33. package/dist/plan/providers-readers.d.ts +34 -11
  34. package/dist/plan/providers-readers.js +72 -15
  35. package/dist/plan/step-system.js +1 -1
  36. package/dist/plan/{edge-worker.d.ts → worker.d.ts} +53 -33
  37. package/dist/plan/{edge-worker.js → worker.js} +92 -54
  38. package/dist/platform.d.ts +3 -3
  39. package/dist/platform.js +1 -1
  40. package/dist/store/postgres.js +7 -7
  41. package/dist/store/schema.d.ts +6 -6
  42. package/dist/store/schema.js +4 -4
  43. package/package.json +1 -1
  44. package/dist/plan/entry-names-key.d.ts +0 -73
@@ -1,14 +1,16 @@
1
+ import { tunnelIngressHostnames } from './hostnames.js';
2
+ import { lockedHostnames } from './locked-hostnames.js';
1
3
  import { appStepKey } from './platform-diff.js';
2
4
  /**
3
- * F7 (#317): one names key per entry with `uses.names`, minted directly
4
- * below the org with the names service (`NamesPort.mintKey({
5
+ * F7 (#317): one edge key per entry with `uses.edge`, minted directly
6
+ * below the org with the edge service (`EdgePort.mintKey({
5
7
  * organisationId, ... })` -- no org-level root key the way files/ai have
6
- * one, since the names service takes the org id itself). `NAMES_URL` and
7
- * `NAMES_API_KEY` go to the entry's env and nowhere else. A names key has no
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
8
10
  * declared renewal window in the issue, so unlike `planEntryAiKeys`/
9
11
  * `planFilesKeys` this mints once and never renews.
10
12
  *
11
- * `NAMES_*` is written in this step, after the app's own `coolify:app`
13
+ * `EDGE_*` is written in this step, after the app's own `coolify:app`
12
14
  * create (which never knows the key yet), so the app's first boot has
13
15
  * neither and its first deploy restarts once this step's env write lands --
14
16
  * the same one-extra-restart shape `planEntryAiKeys`'s `AI_*`/
@@ -17,55 +19,77 @@ import { appStepKey } from './platform-diff.js';
17
19
  * A domain change after mint is not detected (#317 re-review): the key is
18
20
  * bound to the domain passed at mint time, but nothing here records that
19
21
  * domain to compare against a later `org.domain`, so an org that changes
20
- * domains keeps a names key still bound to the old one. Not built --
21
- * resolving it needs a manual revoke-and-re-mint (drop `uses.names`, apply,
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,
22
24
  * re-add it) until this is.
23
25
  */
24
- export const entryNamesUndeliveredScopeKey = (entryKey) => `names_key_undelivered_${entryKey}`;
25
- /** The id of a key that was live when its entry stopped declaring `uses.names`, once handled. */
26
- export const entryNamesRetiredScopeKey = (entryKey) => `names_key_retired_${entryKey}`;
27
- export const ENTRY_NAMES_KEY_DELIVERY_ATTEMPTS = 3;
28
- /** How long an entry's names key lives. The issue names no renewal window, so this mints once and never renews. */
29
- export const NAMES_ENTRY_KEY_DAYS = 365;
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;
30
32
  /** The ISO time a delivered key's mint succeeded -- the only trace of its age, since there is no renewal to track against. */
31
- export const entryNamesMintedAtScopeKey = (entryKey) => `names_key_minted_at_${entryKey}`;
33
+ export const entryEdgeMintedAtScopeKey = (entryKey) => `edge_key_minted_at_${entryKey}`;
32
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. */
33
- export const NAMES_ENTRY_KEY_WARN_WINDOW_DAYS = 30;
35
+ export const EDGE_ENTRY_KEY_WARN_WINDOW_DAYS = 30;
34
36
  const DAY_MS = 24 * 60 * 60 * 1000;
35
- /** The reserved labels last sent to the names service, as that sorted list's own JSON. */
36
- export const NAMES_RESERVED_LABELS_APPLIED_SCOPE_KEY = 'names_reserved_labels_applied';
37
- export const entryNamesKeyName = (org, entry) => `${org.slug}-${entry.key}-names`;
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`;
38
40
  /**
39
- * Every label a declared entry serves (`@`/`*` reserve nothing of their own:
40
- * the bare domain and the wildcard are not names a customer could claim
41
- * under names anyway), plus `www`, which `setReservedLabels`'s own doc
42
- * comment in `executor.ts` explains is reserved unconditionally.
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.
43
44
  */
44
- export function namesReservedLabels(org) {
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 = []) {
45
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);
46
65
  for (const entry of org.hosting.entries) {
47
66
  for (const name of entry.hostnames) {
48
67
  if (name !== '@' && name !== '*')
49
68
  labels.add(name);
50
69
  }
51
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
+ }
52
76
  return [...labels].sort();
53
77
  }
54
78
  /**
55
- * One step per entry whose names key is missing, plus one retire step per
56
- * entry whose `uses.names` was removed but still has a recorded key.
57
- * Blocked, not planned (rule 2), while the platform has no names service
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
58
82
  * wired, the entry's app is not recorded, or a previous mint's delivery
59
83
  * failed.
60
84
  */
61
- export function planEntryNamesKeys(org, recordedIds, plannedSteps, readiness, now = Date.now()) {
85
+ export function planEntryEdgeKeys(org, recordedIds, plannedSteps, readiness, now = Date.now()) {
62
86
  const steps = [];
63
87
  const blocked = [];
64
88
  const planned = (system, kind, k) => plannedSteps.some((p) => p.action.system === system && p.action.kind === kind && p.action.key === k);
65
- // A key whose entry lost `uses.names`, or whose entry is gone entirely --
89
+ // A key whose entry lost `uses.edge`, or whose entry is gone entirely --
66
90
  // either way it is no longer in `declared`, the same "gone" scan
67
91
  // `planEntryAiKeys`/`planFilesKeys` run over their own recorded keys.
68
- // Gated on `namesReady`: without a port there is nothing that could revoke
92
+ // Gated on `edgeReady`: without a port there is nothing that could revoke
69
93
  // it, so it is reported blocked, not planned to a step that would throw.
70
94
  //
71
95
  // Scanned from both the main recorded id and the undelivered marker: a
@@ -73,9 +97,9 @@ export function planEntryNamesKeys(org, recordedIds, plannedSteps, readiness, no
73
97
  // throws before `runSteps` sees a `resourceId`), so scanning the main
74
98
  // prefix alone would never find it to revoke once its entry is gone
75
99
  // (#317 review).
76
- const declared = new Set(org.hosting.entries.filter((e) => e.uses?.names).map((e) => e.key));
77
- const mainPrefix = `names:key:${org.id}:`;
78
- const undeliveredPrefix = 'names_key_undelivered_';
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_';
79
103
  const goneEntryKeys = new Set();
80
104
  for (const [name, id] of Object.entries(recordedIds)) {
81
105
  if (!id)
@@ -95,39 +119,39 @@ export function planEntryNamesKeys(org, recordedIds, plannedSteps, readiness, no
95
119
  // (mint, three failed deliveries, undeclare again) must not hide
96
120
  // behind id A's retired marker just because id A's main-id field is
97
121
  // still truthy (#317 re-review).
98
- if (name.startsWith(mainPrefix) && recordedIds[entryNamesRetiredScopeKey(entryKey)] === id) {
122
+ if (name.startsWith(mainPrefix) && recordedIds[entryEdgeRetiredScopeKey(entryKey)] === id) {
99
123
  continue;
100
124
  }
101
125
  goneEntryKeys.add(entryKey);
102
126
  }
103
127
  for (const entryKey of goneEntryKeys) {
104
- if (!readiness.namesReady) {
128
+ if (!readiness.edgeReady) {
105
129
  blocked.push({
106
- description: `${org.slug}'s ${entryKey} names key is blocked, not planned: the host supplies no names service, or the platform has not enabled it`,
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`,
107
131
  key: `${mainPrefix}${entryKey}`,
108
132
  });
109
133
  continue;
110
134
  }
111
135
  steps.push({
112
136
  action: {
113
- system: 'names',
137
+ system: 'edge',
114
138
  kind: 'key',
115
139
  key: `${org.id}:${entryKey}`,
116
140
  desired: { mode: 'retire' },
117
141
  },
118
142
  step: {
119
- description: `revoke ${org.slug}'s ${entryKey} names key: the entry no longer declares uses.names`,
143
+ description: `revoke ${org.slug}'s ${entryKey} edge key: the entry no longer declares uses.edge`,
120
144
  destroys: true,
121
145
  migrates: false,
122
146
  },
123
147
  });
124
148
  }
125
149
  for (const entry of org.hosting.entries) {
126
- if (!entry.uses?.names)
150
+ if (!entry.uses?.edge)
127
151
  continue;
128
152
  const stepKey = appStepKey(org, entry.key);
129
- const key = `names:key:${stepKey}`;
130
- const liveKeyId = recordedIds[key] && recordedIds[entryNamesRetiredScopeKey(entry.key)] !== recordedIds[key]
153
+ const key = `edge:key:${stepKey}`;
154
+ const liveKeyId = recordedIds[key] && recordedIds[entryEdgeRetiredScopeKey(entry.key)] !== recordedIds[key]
131
155
  ? recordedIds[key]
132
156
  : undefined;
133
157
  if (liveKeyId) {
@@ -140,20 +164,20 @@ export function planEntryNamesKeys(org, recordedIds, plannedSteps, readiness, no
140
164
  // mint could ever clear) can tell the two apart -- a permanently
141
165
  // "blocked" row past day 335 that no action removes is the problem
142
166
  // this prefix exists to avoid.
143
- const mintedAt = recordedIds[entryNamesMintedAtScopeKey(entry.key)];
167
+ const mintedAt = recordedIds[entryEdgeMintedAtScopeKey(entry.key)];
144
168
  if (mintedAt) {
145
- const expiresAt = Date.parse(mintedAt) + NAMES_ENTRY_KEY_DAYS * DAY_MS;
169
+ const expiresAt = Date.parse(mintedAt) + EDGE_ENTRY_KEY_DAYS * DAY_MS;
146
170
  const daysLeft = Math.floor((expiresAt - now) / DAY_MS);
147
- if (daysLeft < NAMES_ENTRY_KEY_WARN_WINDOW_DAYS) {
171
+ if (daysLeft < EDGE_ENTRY_KEY_WARN_WINDOW_DAYS) {
148
172
  blocked.push({
149
- description: `warning: ${org.slug}'s ${entry.key} names 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`,
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`,
150
174
  key,
151
175
  });
152
176
  }
153
177
  }
154
178
  continue;
155
179
  }
156
- const at = `${org.slug}'s ${entry.key} names key`;
180
+ const at = `${org.slug}'s ${entry.key} edge key`;
157
181
  const block = (why) => blocked.push({ description: `${at} is blocked: ${why}`, key });
158
182
  // An undelivered key from a previous mint is no longer a reason to
159
183
  // block forever: nothing here keeps the secret around to retry
@@ -162,8 +186,8 @@ export function planEntryNamesKeys(org, recordedIds, plannedSteps, readiness, no
162
186
  // re-review) -- blocking indefinitely, as a prior round did, left the
163
187
  // orphan live for up to 365 days with no recorded trace an operator
164
188
  // could act on beyond this description.
165
- if (!readiness.namesReady) {
166
- block('the host supplies no names service, or the platform has not enabled it');
189
+ if (!readiness.edgeReady) {
190
+ block('the host supplies no edge service, or the platform has not enabled it');
167
191
  continue;
168
192
  }
169
193
  if (!recordedIds[`coolify:app:${stepKey}`] && !planned('coolify', 'app', stepKey)) {
@@ -172,17 +196,17 @@ export function planEntryNamesKeys(org, recordedIds, plannedSteps, readiness, no
172
196
  }
173
197
  steps.push({
174
198
  action: {
175
- system: 'names',
199
+ system: 'edge',
176
200
  kind: 'key',
177
201
  key: stepKey,
178
202
  desired: {
179
203
  mode: 'mint',
180
- name: entryNamesKeyName(org, entry),
181
- namesUrl: readiness.namesUrl,
204
+ name: entryEdgeKeyName(org, entry),
205
+ edgeUrl: readiness.edgeUrl,
182
206
  },
183
207
  },
184
208
  step: {
185
- description: `mint ${at} below the org, write NAMES_* env, restart the app`,
209
+ description: `mint ${at} below the org, write EDGE_* env, restart the app`,
186
210
  destroys: false,
187
211
  migrates: false,
188
212
  },
@@ -192,41 +216,41 @@ export function planEntryNamesKeys(org, recordedIds, plannedSteps, readiness, no
192
216
  }
193
217
  /**
194
218
  * The reserved-labels step: resent whenever the org's own label set
195
- * (`namesReservedLabels`) differs from what was last sent. While the org
196
- * uses names at all (at least one entry with `uses.names`) that set is
197
- * `namesReservedLabels(org)`; once the last entry drops `uses.names`, this
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
198
222
  * clears whatever was last sent (an empty list) rather than leaving it
199
223
  * stuck in names forever -- a one-time step, same as any other change.
200
224
  */
201
- export function planNamesReservedLabels(org, recordedIds, readiness) {
202
- const usesNames = org.hosting.entries.some((e) => e.uses?.names);
203
- const applied = recordedIds[NAMES_RESERVED_LABELS_APPLIED_SCOPE_KEY];
204
- if (!usesNames && (!applied || applied === '[]')) {
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 === '[]')) {
205
229
  return { step: null, blocked: [] };
206
230
  }
207
- if (!readiness.namesReady) {
231
+ if (!readiness.edgeReady) {
208
232
  return {
209
233
  step: null,
210
234
  blocked: [
211
235
  {
212
- description: `${org.slug}'s reserved labels are blocked: the host supplies no names service, or the platform has not enabled it`,
213
- key: `names:reserved-labels:${org.id}`,
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}`,
214
238
  },
215
239
  ],
216
240
  };
217
241
  }
218
- const labels = usesNames ? namesReservedLabels(org) : [];
242
+ const labels = usesEdge ? edgeReservedLabels(org, platformHostnames) : [];
219
243
  const wantJson = JSON.stringify(labels);
220
244
  if (applied === wantJson) {
221
245
  return { step: null, blocked: [] };
222
246
  }
223
247
  return {
224
248
  step: {
225
- action: { system: 'names', kind: 'reserved-labels', key: org.id, desired: { labels } },
249
+ action: { system: 'edge', kind: 'reserved-labels', key: org.id, desired: { labels } },
226
250
  step: {
227
- description: usesNames
228
- ? `send ${org.slug}'s reserved labels to names: ${labels.join(', ')}`
229
- : `clear ${org.slug}'s reserved labels from names: no entry uses names any more`,
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`,
230
254
  destroys: false,
231
255
  migrates: false,
232
256
  },
@@ -89,6 +89,10 @@ export interface CoolifyPort {
89
89
  * `settings`, not at the top level -- confirmed against live Coolify
90
90
  * 4.3.23 (PR #482 review); `custom_network_aliases` and
91
91
  * `health_check_enabled` are both top-level there.
92
+ *
93
+ * #506: also the port Traefik routes to, so `coolifyAgora` can tell an app
94
+ * that predates passing `ports_exposes` at create apart from one already
95
+ * fixed.
92
96
  */
93
97
  getApplication(uuid: string): Promise<{
94
98
  uuid: string;
@@ -99,6 +103,7 @@ export interface CoolifyPort {
99
103
  };
100
104
  custom_network_aliases?: string | null;
101
105
  health_check_enabled?: boolean;
106
+ ports_exposes?: string;
102
107
  }>;
103
108
  createGithubAppApplication(input: {
104
109
  project_uuid: string;
@@ -565,11 +570,11 @@ export interface WorkersRateLimitBinding {
565
570
  }
566
571
  /**
567
572
  * The Cloudflare Workers calls `cloudflareWorker` makes (#318, F8): deploys
568
- * a plain Worker from an entry's `edge` declaration and routes it on the
573
+ * a plain Worker from an entry's `worker` declaration and routes it on the
569
574
  * org's own zone. Optional on {@link FoundryStepExecutorPorts} -- a host
570
575
  * with no Workers rights on its Cloudflare token (the issue's own "Human
571
576
  * steps": the token needs them added first) wires none, and the planner
572
- * reports every `edge` entry blocked instead of planning a step that would
577
+ * reports every `worker` entry blocked instead of planning a step that would
573
578
  * throw (`FoundryPlanApplyOptions.edgeWorkers`).
574
579
  *
575
580
  * `uploadScript` takes the *declared artifact reference*, not bytes:
@@ -604,10 +609,13 @@ export interface WorkersPort {
604
609
  /** The script name currently bound to `pattern` in `zoneId`, or `undefined` for no route there. */
605
610
  findRoute(zoneId: string, pattern: string): Promise<{
606
611
  id: string;
607
- script: string;
612
+ script?: string;
608
613
  } | undefined>;
609
- /** Creates the route if absent; `cloudflareWorker` only calls this once `findRoute` has confirmed the pattern is foundry's own or unclaimed. */
610
- createRoute(zoneId: string, pattern: string, script: string): Promise<{
614
+ /**
615
+ * Creates the route if absent; `cloudflareWorker` only calls this once `findRoute` has confirmed the pattern is foundry's own or unclaimed.
616
+ * 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.
617
+ */
618
+ createRoute(zoneId: string, pattern: string, script?: string): Promise<{
611
619
  id: string;
612
620
  }>;
613
621
  /** A no-op, not a throw, when `routeId` is already gone. */
@@ -1338,22 +1346,22 @@ export interface AiOrganisationPort {
1338
1346
  }): Promise<unknown>;
1339
1347
  }
1340
1348
  /**
1341
- * #317 (F7): the names service's own management API
1342
- * (https://github.com/wtfalch/names, live on main, offering `mintKey`,
1349
+ * #317 (F7): the edge service's own management API
1350
+ * (https://github.com/wtfalch/edge, live on main, offering `mintKey`,
1343
1351
  * `revokeKey` and `setReservedLabels`) -- this port states the shape this
1344
1352
  * executor assumes of it. `organisationId` is the org itself: unlike
1345
- * `AiOrganisationPort.mintNestedKey`, names takes no separate minter key
1346
- * id, so `namesEntryKey` mints directly below the org. A real adapter
1353
+ * `AiOrganisationPort.mintNestedKey`, edge takes no separate minter key
1354
+ * id, so `edgeEntryKey` mints directly below the org. A real adapter
1347
1355
  * wiring this port into `platform-providers.ts` is tracked separately
1348
1356
  * (https://github.com/wtfalch/archon/issues/339).
1349
1357
  */
1350
- export interface NamesPort {
1358
+ export interface EdgePort {
1351
1359
  /**
1352
- * `domain` is required: names's own `mintKey` refuses a request without a
1360
+ * `domain` is required: edge's own `mintKey` refuses a request without a
1353
1361
  * lowercase hostname and binds the key to it
1354
- * (`wtfalch/names`'s `packages/service/src/keys.ts`) -- `namesEntryKey`
1355
- * passes `org.domain`, which `validateEntryNames` already requires before
1356
- * `uses.names` is accepted.
1362
+ * (`wtfalch/edge`'s `packages/service/src/keys.ts`) -- `edgeEntryKey`
1363
+ * passes `org.domain`, which `validateEntryEdge` already requires before
1364
+ * `uses.edge` is accepted.
1357
1365
  */
1358
1366
  mintKey(input: {
1359
1367
  organisationId: string;
@@ -1407,6 +1415,8 @@ export interface PlatformOrgPortSource {
1407
1415
  files(): Promise<FilesOrganisationPort>;
1408
1416
  ai(): Promise<AiOrganisationPort>;
1409
1417
  jobs(): Promise<JobsSchedulerPort>;
1418
+ /** The platform's shared sign-in host, or `null` when none is declared. */
1419
+ authHostname?(): Promise<string | null>;
1410
1420
  }
1411
1421
  export interface FoundryStepExecutorPorts {
1412
1422
  coolify: CoolifyPort;
@@ -1437,14 +1447,14 @@ export interface FoundryStepExecutorPorts {
1437
1447
  filesOrganisation?: FilesOrganisationPort;
1438
1448
  aiOrganisation?: AiOrganisationPort;
1439
1449
  /**
1440
- * #317 (F7): mints an entry's names key -- see {@link NamesPort}'s own
1450
+ * #317 (F7): mints an entry's edge key -- see {@link EdgePort}'s own
1441
1451
  * doc comment. Optional the same way `jobsScheduler` is: a host with no
1442
- * names service deployed builds the executor without one, and tells the
1443
- * planner so (`FoundryPlanApplyOptions.names`), which then reports each
1444
- * entry's `uses.names` step as blocked instead of planning one that would
1452
+ * edge service deployed builds the executor without one, and tells the
1453
+ * planner so (`FoundryPlanApplyOptions.edge`), which then reports each
1454
+ * entry's `uses.edge` step as blocked instead of planning one that would
1445
1455
  * throw.
1446
1456
  */
1447
- names?: NamesPort;
1457
+ edge?: EdgePort;
1448
1458
  /**
1449
1459
  * #177: builds the files, AI and jobs ports from the platform's addresses
1450
1460
  * and held keys. A port set above overrides it (tests); with neither, the
@@ -1468,9 +1478,9 @@ export interface FoundryStepExecutorPorts {
1468
1478
  */
1469
1479
  broker?: BrokerPort;
1470
1480
  /**
1471
- * #318: deploys and routes an entry's `edge` Worker -- see
1481
+ * #318: deploys and routes an entry's `worker` Worker -- see
1472
1482
  * {@link WorkersPort}'s own doc comment. Optional the same way `broker` is:
1473
- * without it the plan reports each `edge` entry blocked (the host also
1483
+ * without it the plan reports each `worker` entry blocked (the host also
1474
1484
  * sets `FoundryPlanApplyOptions.edgeWorkers`).
1475
1485
  */
1476
1486
  workers?: WorkersPort;
@@ -1618,10 +1628,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1618
1628
  */
1619
1629
  private cloudflareRateLimit;
1620
1630
  /**
1621
- * `plan/diff.ts`'s `planEdgeWorkers` (#318, F8): deploys one entry's
1622
- * `edge` Worker and routes it on the org's own zone. Throws by name
1631
+ * `plan/diff.ts`'s `planWorkers` (#318, F8): deploys one entry's
1632
+ * `worker` Worker and routes it on the org's own zone. Throws by name
1623
1633
  * without a {@link WorkersPort}, the same `jobsRegistration` shape --
1624
- * `planEdgeWorkers` reports the step blocked instead while the host has
1634
+ * `planWorkers` reports the step blocked instead while the host has
1625
1635
  * none, so this only fires against a stale plan. The route is checked
1626
1636
  * before upload: a foreign route already on the pattern is refused, never
1627
1637
  * adopted or replaced (`cloudflareAccess`'s own precedent for a foreign
@@ -1638,6 +1648,14 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1638
1648
  * live state actually matches it.
1639
1649
  */
1640
1650
  private cloudflareWorker;
1651
+ /**
1652
+ * Makes the no-Worker routes recorded for an entry match `patterns`: deletes
1653
+ * the ones no longer wanted (all of them if the zone changed), creates the
1654
+ * missing ones without a script. A route already on a pattern that is bound
1655
+ * to a Worker is foreign and refused; one with no script is adopted. Records
1656
+ * and persists after each change.
1657
+ */
1658
+ private syncWorkerExclusions;
1641
1659
  /**
1642
1660
  * The zone id a recorded route lives in, or `undefined` only when that
1643
1661
  * zone is *positively* gone -- its apex resolves to no zone, or to a
@@ -1651,15 +1669,15 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1651
1669
  * live on the old one, the exact bug a recorded-per-route zone exists to
1652
1670
  * avoid (#318 re-review).
1653
1671
  */
1654
- private liveEdgeZone;
1672
+ private liveWorkerZone;
1655
1673
  /** Deletes the route an interrupted apply was creating, if `findRoute` shows it exists, points at that apply's script, and is not `keepRouteId`. */
1656
- private deletePendingEdgeRoute;
1674
+ private deletePendingWorkerRoute;
1657
1675
  /**
1658
1676
  * Retire and destroy share this: delete the recorded route on its own
1659
1677
  * zone, then any route an interrupted apply created but never recorded,
1660
1678
  * then the script. Records nothing -- the caller does.
1661
1679
  */
1662
- private teardownEdgeWorker;
1680
+ private teardownWorker;
1663
1681
  private zitadelIssuer;
1664
1682
  /**
1665
1683
  * `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
@@ -2320,11 +2338,25 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2320
2338
  * actually differs, or whenever the last restart this step confirmed
2321
2339
  * ({@link AGORA_ACTIVATED_ENV_SCOPE_KEY}) is not this exact env (an org
2322
2340
  * applied before this existed, whose live env already matched desired
2323
- * with nothing ever confirmed running it). A brand new app has nothing
2324
- * running yet to restart. The activation marker is recorded last, after
2325
- * the restart (if any) returns without throwing, so a failed restart
2326
- * leaves it stale and the next plan replans this step even though
2327
- * Coolify's own env store already matches desired.
2341
+ * with nothing ever confirmed running it). The activation marker is
2342
+ * recorded last, after the restart (if any) returns without throwing, so a
2343
+ * failed restart leaves it stale and the next plan replans this step even
2344
+ * though Coolify's own env store already matches desired.
2345
+ *
2346
+ * #504: a brand new app is never restarted -- but it is deployed, once
2347
+ * ({@link AGORA_DEPLOYED_SCOPE_KEY}). `createGithubAppApplication` never
2348
+ * sets `instant_deploy`, and Coolify defaults it to false, so without an
2349
+ * explicit deploy the app sat created but never built or started.
2350
+ *
2351
+ * #506: `ports_exposes: AGORA_APP_PORTS_EXPOSES` goes on the create call
2352
+ * itself, so Traefik has a port to route to from the moment the app
2353
+ * exists. An app `findOrCreateCallsApp` instead *found* may predate this
2354
+ * fix and carry no `ports_exposes` at all (or the wrong one), so its live
2355
+ * value is read and, when it differs, patched -- and, since the container
2356
+ * listens on `ports_exposes` only at its next deploy (the same rule
2357
+ * `coolifyApp`'s own `settingsNeedDeploy` follows for a port change
2358
+ * there), redeployed even on a run that already has its one #504 deploy
2359
+ * behind it.
2328
2360
  */
2329
2361
  private coolifyAgora;
2330
2362
  /**
@@ -2349,6 +2381,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2349
2381
  *
2350
2382
  * `AUTH_APP_KEY` is minted once here; its broker entry is the next step
2351
2383
  * (`bouleBrokerEntry`).
2384
+ *
2385
+ * #504: a brand new app is never restarted -- but it is deployed, once
2386
+ * ({@link BOULE_DEPLOYED_SCOPE_KEY}), the same reason and the same marker
2387
+ * shape {@link coolifyAgora}'s own doc comment explains.
2352
2388
  */
2353
2389
  private coolifyBoule;
2354
2390
  /**
@@ -2490,6 +2526,15 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2490
2526
  * shared-hostname collision this ticket exists to refuse. An org re-running
2491
2527
  * its own already-recorded zone is unaffected: the store then names this
2492
2528
  * same org as the owner.
2529
+ *
2530
+ * #510: also records the zone's own id under the flat `cloudflare_zone`
2531
+ * scope key, alongside the `cloudflare:zone:<domain>` resource id `runSteps`
2532
+ * already records from this method's return value. Without it, nothing
2533
+ * ever gives `ProvidersCloudflareReader` a zone id to resolve this org's
2534
+ * scope with (`providers-readers.ts`'s `storeScopeResolver` doc comment),
2535
+ * so that reader's `listResources` always returned `[]` and `diff.ts`'s
2536
+ * `planZone` re-proposed "provision a Cloudflare zone" on every apply, even
2537
+ * once the zone was live.
2493
2538
  */
2494
2539
  private cloudflareZone;
2495
2540
  /**
@@ -2716,21 +2761,21 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2716
2761
  */
2717
2762
  private revokeEntryKey;
2718
2763
  /**
2719
- * F7 (#317): mints one entry's names key directly below the org (names
2720
- * takes no separate minter key id, unlike ai/files), writes `NAMES_URL`
2721
- * and `NAMES_API_KEY` to the entry's app env, then restarts it. Retires
2722
- * the key when `uses.names` is removed. Follows the "one mint" rule
2764
+ * F7 (#317): mints one entry's edge key directly below the org (edge
2765
+ * takes no separate minter key id, unlike ai/files), writes `EDGE_URL`
2766
+ * and `EDGE_API_KEY` to the entry's app env, then restarts it. Retires
2767
+ * the key when `uses.edge` is removed. Follows the "one mint" rule
2723
2768
  * (CLAUDE.md "Platform steps"): the id is recorded as undelivered and
2724
2769
  * persisted before delivery is attempted, so a crash or a failed write
2725
- * never leads to a second mint -- `planEntryNamesKeys` blocks instead.
2770
+ * never leads to a second mint -- `planEntryEdgeKeys` blocks instead.
2726
2771
  */
2727
- private namesEntryKey;
2772
+ private edgeEntryKey;
2728
2773
  /**
2729
- * F7 (#317): `plan/entry-names-key.ts`'s `planNamesReservedLabels` --
2730
- * tells the names service every label this org's own entries already
2731
- * hold, so names refuses a customer claiming one of them.
2774
+ * F7 (#317): `plan/entry-edge-key.ts`'s `planEdgeReservedLabels` --
2775
+ * tells the edge service every label this org's own entries already
2776
+ * hold, so edge refuses a customer claiming one of them.
2732
2777
  */
2733
- private namesReservedLabels;
2778
+ private edgeReservedLabels;
2734
2779
  /**
2735
2780
  * F6b: mints or renews the org's own files key, or revokes the ones a
2736
2781
  * renewal replaced. The key is minted below the files organisation's owner