@wtfalch/foundry 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,11 +23,11 @@ export const DEFAULT_GIT_REF = 'main';
23
23
  */
24
24
  export const DEFAULT_MAIL_IMAGE_TAG = VERSIONS.stalwart;
25
25
  /**
26
- * Every platform service except `sfu` and `names` is enabled, like the other
26
+ * Every platform service except `sfu` and `edge` is enabled, like the other
27
27
  * steps: a service whose dependencies the host did not supply is reported
28
28
  * blocked, not planned. `sfu` stays off because it needs a held SSH key and
29
- * host fingerprint, a separate human step (issue 170). `names` stays off
30
- * because the names service (N1) does not exist yet -- a human deploys it
29
+ * host fingerprint, a separate human step (issue 170). `edge` stays off
30
+ * because the edge service (N1) does not exist yet -- a human deploys it
31
31
  * and sets its hostname before enabling it (#317's own "Human steps").
32
32
  */
33
33
  const SERVICES_ENABLED_BY_DEFAULT = new Set([
package/dist/index.d.ts CHANGED
@@ -20,7 +20,7 @@ export type { FoundryStoreDb } from './store/postgres.js';
20
20
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type AppliedVersionRow, type NewAppliedVersionRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
21
21
  export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, StepSystem, PlanDetail, PlatformPlanDetail, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
22
22
  export { AdoptRefusedError } from './plan/index.js';
23
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_NAMES_KEY, PLATFORM_NAMES_KEY_REF, PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
23
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
24
24
  export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
25
25
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
26
26
  export type { PlatformOrgClientFactories } from './plan/index.js';
@@ -35,7 +35,7 @@ export { checkMailIdentityAudience } from './plan/index.js';
35
35
  export type { MailAllowlistApp } from './plan/index.js';
36
36
  export { checkMailAppsAllowlist } from './plan/index.js';
37
37
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
38
- export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformNamesDeps, PlatformNamesRuntime, } from './plan/index.js';
38
+ export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformEdgeRuntime, } from './plan/index.js';
39
39
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './plan/index.js';
40
40
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './plan/index.js';
41
41
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, cre
12
12
  export { createPostgresAuditLog } from './store/audit.js';
13
13
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
14
14
  export { AdoptRefusedError } from './plan/index.js';
15
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_NAMES_KEY, PLATFORM_NAMES_KEY_REF, PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
15
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
16
16
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
17
17
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
18
18
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
@@ -0,0 +1,107 @@
1
+ -- @wtfalch/foundry: the names service is renamed edge, and the hosting entry
2
+ -- field `edge` (a Cloudflare Worker) is renamed `worker`. No behaviour change.
3
+ -- Mirrors `src/store/schema.ts`'s `foundryDeclaredPlatform` columns.
4
+ --
5
+ -- 1. foundry_declared_platform: names_enabled / names_hostname /
6
+ -- names_git_ref become edge_*. Each rename runs only while the old column
7
+ -- exists, so a second run is a no-op.
8
+ -- 2. The hostname moves from `names.<apex>` to `edge.<apex>` only where it
9
+ -- still equals the old default (0043); an operator's own value stays.
10
+ -- 3. Recorded ids are rewritten in place: `names:key:`, `names:reserved-labels:`
11
+ -- and `platform:names` (ids and step ids), and the colon-free scope keys
12
+ -- `names_*` and `*_platform_names*`. The applied-hostname value is left
13
+ -- alone, so the planner sees a changed hostname and applies the step again.
14
+ -- Every WHERE clause matches only text that still holds an old key, so a
15
+ -- second run changes nothing. `foundry_audit_events` is a signed chain and
16
+ -- is not touched.
17
+ -- 4. foundry_declared_orgs.hosting_entries: each entry's `uses.names` becomes
18
+ -- `uses.edge`, and its `edge` becomes `worker`.
19
+
20
+ do $$
21
+ begin
22
+ if exists (select 1 from information_schema.columns
23
+ where table_schema = current_schema()
24
+ and table_name = 'foundry_declared_platform' and column_name = 'names_enabled') then
25
+ alter table foundry_declared_platform rename column names_enabled to edge_enabled;
26
+ end if;
27
+ if exists (select 1 from information_schema.columns
28
+ where table_schema = current_schema()
29
+ and table_name = 'foundry_declared_platform' and column_name = 'names_hostname') then
30
+ alter table foundry_declared_platform rename column names_hostname to edge_hostname;
31
+ end if;
32
+ if exists (select 1 from information_schema.columns
33
+ where table_schema = current_schema()
34
+ and table_name = 'foundry_declared_platform' and column_name = 'names_git_ref') then
35
+ alter table foundry_declared_platform rename column names_git_ref to edge_git_ref;
36
+ end if;
37
+ end
38
+ $$;
39
+
40
+ update foundry_declared_platform
41
+ set edge_hostname = 'edge.' || apex
42
+ where edge_hostname = 'names.' || apex;
43
+
44
+ -- The old-to-new text rewrite for recorded ids, applied to the jsonb as text.
45
+ create or replace function pg_temp.names_to_edge(value jsonb) returns jsonb
46
+ language sql immutable as $fn$
47
+ select replace(
48
+ replace(
49
+ replace(
50
+ replace(
51
+ replace(value::text, '"names:key:', '"edge:key:'),
52
+ '"names:reserved-labels:', '"edge:reserved-labels:'),
53
+ '"names_', '"edge_'),
54
+ '_platform_names', '_platform_edge'),
55
+ 'platform:names', 'platform:edge')::jsonb
56
+ $fn$;
57
+
58
+ update foundry_declared_platform
59
+ set service_ids = pg_temp.names_to_edge(service_ids)
60
+ where service_ids::text ~ '"names[:_]|_platform_names|platform:names';
61
+
62
+ update foundry_applied_state
63
+ set service_ids = pg_temp.names_to_edge(service_ids)
64
+ where service_ids::text ~ '"names[:_]|_platform_names|platform:names';
65
+
66
+ update foundry_applied_state
67
+ set checkpoints = pg_temp.names_to_edge(checkpoints)
68
+ where checkpoints::text ~ '"names[:_]|_platform_names|platform:names';
69
+
70
+ update foundry_applied_state
71
+ set destroy = pg_temp.names_to_edge(destroy)
72
+ where destroy is not null
73
+ and destroy::text ~ '"names[:_]|_platform_names|platform:names';
74
+
75
+ update foundry_runs
76
+ set step_ids = pg_temp.names_to_edge(step_ids)
77
+ where step_ids::text ~ '"names[:_]|_platform_names|platform:names';
78
+
79
+ update foundry_runs
80
+ set checkpoints = pg_temp.names_to_edge(checkpoints)
81
+ where checkpoints::text ~ '"names[:_]|_platform_names|platform:names';
82
+
83
+ update foundry_runs
84
+ set failure = pg_temp.names_to_edge(failure)
85
+ where failure is not null
86
+ and failure::text ~ '"names[:_]|_platform_names|platform:names';
87
+
88
+ update foundry_declared_orgs o
89
+ set hosting_entries = (
90
+ select coalesce(jsonb_agg(
91
+ case
92
+ when jsonb_typeof(s -> 'uses') = 'object' and (s -> 'uses') ? 'names'
93
+ then jsonb_set(s, '{uses}',
94
+ ((s -> 'uses') - 'names') || jsonb_build_object('edge', s -> 'uses' -> 'names'))
95
+ else s
96
+ end order by t.ord), '[]'::jsonb)
97
+ from jsonb_array_elements(o.hosting_entries) with ordinality as t(e, ord),
98
+ lateral (select case
99
+ when jsonb_typeof(t.e) = 'object' and t.e ? 'edge'
100
+ then (t.e - 'edge') || jsonb_build_object('worker', t.e -> 'edge')
101
+ else t.e
102
+ end as s) as r(s))
103
+ where exists (
104
+ select 1 from jsonb_array_elements(o.hosting_entries) e
105
+ where jsonb_typeof(e) = 'object'
106
+ and (e ? 'edge' or (jsonb_typeof(e -> 'uses') = 'object' and (e -> 'uses') ? 'names'))
107
+ );
package/dist/org.d.ts CHANGED
@@ -335,26 +335,26 @@ export interface HostingEntry {
335
335
  * F8: a Cloudflare Worker foundry deploys from this entry's own repo,
336
336
  * beside the app, on the org's own zone. Only our own repos may use this
337
337
  * (#318: outside customers need Workers for Platforms, not built yet), so
338
- * `validateEntryEdge` refuses it unless `repo` is set (every non-null
338
+ * `validateEntryWorker` refuses it unless `repo` is set (every non-null
339
339
  * `repo` already matches `wtfalch/...`, {@link REPO}) and the route's zone
340
340
  * is the org's own domain.
341
341
  */
342
- edge?: EdgeConfig;
342
+ worker?: WorkerConfig;
343
343
  }
344
344
  /**
345
345
  * One Cloudflare rate limiting binding on a Worker (#318) -- Cloudflare's own
346
346
  * "simple" shape: a fixed number of requests per period, where `period` is
347
347
  * the only two values Cloudflare's rate limiting binding accepts.
348
348
  */
349
- export interface EdgeRateLimitBinding {
349
+ export interface WorkerRateLimitBinding {
350
350
  name: string;
351
351
  namespaceId: string;
352
352
  limit: number;
353
353
  period: 10 | 60;
354
354
  }
355
- /** {@link HostingEntry.edge}'s bindings, one list per binding kind -- only rate limits today. */
356
- export interface EdgeBindings {
357
- rateLimits: EdgeRateLimitBinding[];
355
+ /** {@link HostingEntry.worker}'s bindings, one list per binding kind -- only rate limits today. */
356
+ export interface WorkerBindings {
357
+ rateLimits: WorkerRateLimitBinding[];
358
358
  }
359
359
  /**
360
360
  * A plain Worker deployed beside a hosting entry (#318), named by one route
@@ -365,14 +365,14 @@ export interface EdgeBindings {
365
365
  * Worker setup": "The app's automated build process builds the bundle
366
366
  * outside Foundry; Foundry consumes and deploys the artifact through the
367
367
  * provider boundary... No manual build step and no source checkout/bundling
368
- * in Foundry"). `validateEntryEdge` refuses anything that is not an
368
+ * in Foundry"). `validateEntryWorker` refuses anything that is not an
369
369
  * `https://` URL -- a bare path or relative file name is exactly the shape
370
370
  * this decision rules out.
371
371
  */
372
- export interface EdgeConfig {
372
+ export interface WorkerConfig {
373
373
  /** Where foundry fetches the built module from, behind `WorkersPort` -- never checked out or bundled here. */
374
374
  artifactUrl: string;
375
- /** The built module's sha256, lowercase hex. Verified against the fetched bytes before upload, and the sole signal `planEdgeWorkers` diffs on: an org that rebuilds under an unchanged URL redeploys only when this changes. */
375
+ /** The built module's sha256, lowercase hex. Verified against the fetched bytes before upload, and the sole signal `planWorkers` diffs on: an org that rebuilds under an unchanged URL redeploys only when this changes. */
376
376
  artifactSha256: string;
377
377
  /**
378
378
  * One route per entry today. A wildcard route (`*.<domain>/*`) matches
@@ -386,7 +386,7 @@ export interface EdgeConfig {
386
386
  * separate entry with that exact route; there is no exclusion list.
387
387
  */
388
388
  route: string;
389
- bindings?: EdgeBindings;
389
+ bindings?: WorkerBindings;
390
390
  }
391
391
  /**
392
392
  * Sign-in for one entry: one ZITADEL client and one broker entry, signing in
@@ -441,16 +441,16 @@ export interface HostingEntryUses {
441
441
  ai?: EntryAi;
442
442
  files?: EntryFiles;
443
443
  /**
444
- * F7 (#317): the entry gets its own names key, minted directly below the
445
- * org with the names service, and `NAMES_URL`/`NAMES_API_KEY` in its env.
446
- * Serves only the org's own domain (names claims `<label>.<org's domain>`),
447
- * so `validateEntryNames` refuses it without `hosting.usesDomain` and an
444
+ * F7 (#317): the entry gets its own edge key, minted directly below the
445
+ * org with the edge service, and `EDGE_URL`/`EDGE_API_KEY` in its env.
446
+ * Serves only the org's own domain (edge claims `<label>.<org's domain>`),
447
+ * so `validateEntryEdge` refuses it without `hosting.usesDomain` and an
448
448
  * org `domain`.
449
449
  */
450
- names?: true;
450
+ edge?: true;
451
451
  }
452
- /** The env names `uses.names` writes; `env` may not name them (#317). */
453
- export declare const NAMES_ENTRY_ENV_NAMES: readonly ["NAMES_URL", "NAMES_API_KEY"];
452
+ /** The env names `uses.edge` writes; `env` may not name them (#317). */
453
+ export declare const EDGE_ENTRY_ENV_NAMES: readonly ["EDGE_URL", "EDGE_API_KEY"];
454
454
  /**
455
455
  * A plain string is pushed as it is, so `validateHosting` refuses one that
456
456
  * looks like a secret. `{ from }` names a value the plan already mints.
package/dist/org.js CHANGED
@@ -36,8 +36,8 @@ export const FILES_ENTRY_ENV_NAMES = [
36
36
  'FILES_API_KEY',
37
37
  ];
38
38
  export const filesCollectionEnvName = (name) => `FILES_COLLECTION_${name}_ID`;
39
- /** The env names `uses.names` writes; `env` may not name them (#317). */
40
- export const NAMES_ENTRY_ENV_NAMES = ['NAMES_URL', 'NAMES_API_KEY'];
39
+ /** The env names `uses.edge` writes; `env` may not name them (#317). */
40
+ export const EDGE_ENTRY_ENV_NAMES = ['EDGE_URL', 'EDGE_API_KEY'];
41
41
  /** The org's `app` entry: today's one app. */
42
42
  export function appEntry(hosting) {
43
43
  return hosting.entries.find((entry) => entry.key === APP_ENTRY_KEY);
@@ -132,29 +132,29 @@ export function validateHosting(hosting, names) {
132
132
  const invalidFiles = validateEntryFiles(entry, hosting, names?.domain);
133
133
  if (invalidFiles)
134
134
  return invalidFiles;
135
- const invalidEdge = validateEntryEdge(entry, hosting, names?.domain, names?.locked);
136
- if (invalidEdge)
137
- return invalidEdge;
138
- const invalidNamesUse = validateEntryNames(entry, hosting, names?.domain);
139
- if (invalidNamesUse)
140
- return invalidNamesUse;
135
+ const invalidWorker = validateEntryWorker(entry, hosting, names?.domain);
136
+ if (invalidWorker)
137
+ return invalidWorker;
138
+ const invalidEdgeUse = validateEntryEdge(entry, hosting, names?.domain);
139
+ if (invalidEdgeUse)
140
+ return invalidEdgeUse;
141
141
  }
142
142
  if (hosting.entries.filter((entry) => entry.uses?.ai?.webhookPath !== undefined).length > 1) {
143
143
  return 'at most one hosting entry may set uses.ai webhookPath: ai keeps one webhook per organisation';
144
144
  }
145
- // #318 re-review: two entries declaring the same edge route both throw at
145
+ // #318 re-review: two entries declaring the same worker route both throw at
146
146
  // apply (`cloudflareWorker` refuses a route already bound to a Worker
147
147
  // that is not its own), one of which stops Agora and everything planned
148
148
  // after it provisioning at all -- refused here instead, before an apply
149
149
  // ever reaches it.
150
- const seenEdgeRoutes = new Set();
150
+ const seenWorkerRoutes = new Set();
151
151
  for (const entry of hosting.entries) {
152
- if (!entry.edge)
152
+ if (!entry.worker)
153
153
  continue;
154
- if (seenEdgeRoutes.has(entry.edge.route)) {
155
- return `hosting.entries '${entry.key}' edge route '${entry.edge.route}' is already declared by another entry`;
154
+ if (seenWorkerRoutes.has(entry.worker.route)) {
155
+ return `hosting.entries '${entry.key}' worker route '${entry.worker.route}' is already declared by another entry`;
156
156
  }
157
- seenEdgeRoutes.add(entry.edge.route);
157
+ seenWorkerRoutes.add(entry.worker.route);
158
158
  }
159
159
  const invalidNames = validateEntryHostnames(hosting, names);
160
160
  if (invalidNames)
@@ -438,27 +438,27 @@ function validateEntryFiles(entry, hosting, domain) {
438
438
  return null;
439
439
  }
440
440
  /** A route pattern's zone apex: the host part before the first `/`, with one leading `*.` label stripped. */
441
- function edgeRouteZone(route) {
441
+ function workerRouteZone(route) {
442
442
  const slash = route.indexOf('/');
443
443
  if (slash < 1)
444
444
  return null;
445
445
  const host = route.slice(0, slash);
446
446
  return host.startsWith('*.') ? host.slice(2) : host;
447
447
  }
448
- const EDGE_ROUTE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+\/.*$/;
449
- const EDGE_SHA256 = /^[0-9a-f]{64}$/;
448
+ const WORKER_ROUTE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+\/.*$/;
449
+ const WORKER_SHA256 = /^[0-9a-f]{64}$/;
450
450
  /**
451
- * Restated, not shared, from `plan/edge-worker.ts`'s `EDGE_WORKER_ORG_HASH_LENGTH`/
452
- * `EDGE_WORKER_NAME_MAX_LENGTH` -- importing them here would import that
451
+ * Restated, not shared, from `plan/worker.ts`'s `WORKER_ORG_HASH_LENGTH`/
452
+ * `WORKER_NAME_MAX_LENGTH` -- importing them here would import that
453
453
  * module's own `DeclaredOrg`/`HostingEntry` imports right back into this
454
- * one. `edgeWorkerName`'s shape is `${8-hex-char hash}-${entry.key}-edge`;
454
+ * one. `workerName`'s shape is `${8-hex-char hash}-${entry.key}-edge`;
455
455
  * the hash and the `-edge` suffix are fixed length, so the entry key's own
456
456
  * budget against Cloudflare's 63-character Worker name limit is fixed too.
457
457
  */
458
- const EDGE_WORKER_NAME_FIXED_LENGTH = 8 + 1 + '-edge'.length;
459
- const EDGE_WORKER_NAME_MAX_LENGTH = 63;
458
+ const WORKER_NAME_FIXED_LENGTH = 8 + 1 + '-edge'.length;
459
+ const WORKER_NAME_MAX_LENGTH = 63;
460
460
  /**
461
- * `edge`: the entry's own repo (never the shared standard app -- entries
461
+ * `worker`: the entry's own repo (never the shared standard app -- entries
462
462
  * with `repo: null` have no Worker source of their own), an `https://`
463
463
  * artifact URL with a lowercase-hex sha256 (never a bare path or relative
464
464
  * file -- estate DECISIONS.md, 2026-10-04, rules out any checkout or
@@ -467,22 +467,22 @@ const EDGE_WORKER_NAME_MAX_LENGTH = 63;
467
467
  * binding's `period` restricted to the two values Cloudflare's simple rate
468
468
  * limiting binding accepts.
469
469
  */
470
- function validateEntryEdge(entry, hosting, domain, locked) {
471
- const edge = entry.edge;
472
- if (!edge)
470
+ function validateEntryWorker(entry, hosting, domain) {
471
+ const worker = entry.worker;
472
+ if (!worker)
473
473
  return null;
474
- const at = `hosting.entries '${entry.key}' edge`;
474
+ const at = `hosting.entries '${entry.key}' worker`;
475
475
  if (!hosting.enabled)
476
476
  return `${at} requires hosting.enabled`;
477
477
  if (entry.repo === null) {
478
478
  return `${at} needs its own repo (repo: null is the shared standard app, not a Worker source)`;
479
479
  }
480
- if (entry.key.length > EDGE_WORKER_NAME_MAX_LENGTH - EDGE_WORKER_NAME_FIXED_LENGTH) {
481
- return `${at} entry key is too long: the deployed Worker's name (an 8-character org hash, the key, and "-edge") must fit Cloudflare's ${EDGE_WORKER_NAME_MAX_LENGTH}-character limit`;
480
+ if (entry.key.length > WORKER_NAME_MAX_LENGTH - WORKER_NAME_FIXED_LENGTH) {
481
+ return `${at} entry key is too long: the deployed Worker's name (an 8-character org hash, the key, and "-edge") must fit Cloudflare's ${WORKER_NAME_MAX_LENGTH}-character limit`;
482
482
  }
483
483
  let artifactUrl;
484
484
  try {
485
- artifactUrl = new URL(edge.artifactUrl);
485
+ artifactUrl = new URL(worker.artifactUrl);
486
486
  }
487
487
  catch {
488
488
  return `${at} artifactUrl must be an absolute URL, not a bare path or relative file`;
@@ -497,32 +497,22 @@ function validateEntryEdge(entry, hosting, domain, locked) {
497
497
  return `${at} artifactUrl must not carry a query string (a signed or credentialed URL ends up stored verbatim in recorded scope ids)`;
498
498
  }
499
499
  // `URL.hash` is '' for a bare trailing `#`, so the raw string is checked.
500
- if (edge.artifactUrl.includes('#')) {
500
+ if (worker.artifactUrl.includes('#')) {
501
501
  return `${at} artifactUrl must not carry a fragment (refused for the same reason as a query string)`;
502
502
  }
503
- if (!EDGE_SHA256.test(edge.artifactSha256)) {
503
+ if (!WORKER_SHA256.test(worker.artifactSha256)) {
504
504
  return `${at} artifactSha256 must be 64 lowercase hex characters`;
505
505
  }
506
- if (!EDGE_ROUTE.test(edge.route)) {
506
+ if (!WORKER_ROUTE.test(worker.route)) {
507
507
  return `${at} route must be a hostname (an optional leading '*.' label, then at least two more labels) and a path, joined by '/'`;
508
508
  }
509
- const zone = edgeRouteZone(edge.route);
509
+ const zone = workerRouteZone(worker.route);
510
510
  if (!domain || zone !== domain) {
511
- return `${at} route '${edge.route}' must be on the org's own domain${domain ? ` (${domain})` : ''}: edge is only for our own projects`;
512
- }
513
- // #318 re-review: a Worker route has no path-based exclusion of its own,
514
- // so a wildcard (`*.<domain>/*`) captures every hostname foundry already
515
- // owns under the domain -- Agora, Boule, the login host and the rest of
516
- // `locked-hostnames.ts`'s set -- not only the org's other entries. (A
517
- // non-wildcard route can only ever be the bare apex here -- the check
518
- // just above requires `edgeRouteZone(edge.route) === domain`, and that
519
- // function returns a non-wildcard route's host unchanged -- and the apex
520
- // alone is never itself a locked name, so there is no direct-match case
521
- // to refuse alongside this one.)
522
- if (locked && locked.size > 0 && edge.route.startsWith('*.')) {
523
- return `${at} route '${edge.route}' is a wildcard: it would also capture ${[...locked].sort().join(', ')}, every hostname foundry already owns under this domain`;
524
- }
525
- for (const limit of edge.bindings?.rateLimits ?? []) {
511
+ return `${at} route '${worker.route}' must be on the org's own domain${domain ? ` (${domain})` : ''}: worker is only for our own projects`;
512
+ }
513
+ // A wildcard route (`*.<domain>/*`) is allowed beside hostnames foundry owns:
514
+ // `planWorkers` plans a no-Worker route for each of them, so they stay out of it.
515
+ for (const limit of worker.bindings?.rateLimits ?? []) {
526
516
  if (!limit.name.trim())
527
517
  return `${at} bindings.rateLimits needs a non-empty name`;
528
518
  if (!limit.namespaceId.trim())
@@ -537,23 +527,23 @@ function validateEntryEdge(entry, hosting, domain, locked) {
537
527
  return null;
538
528
  }
539
529
  /**
540
- * `uses.names`: serves only the org's own domain (names claims
530
+ * `uses.edge`: serves only the org's own domain (edge claims
541
531
  * `<label>.<domain>`), so it needs `hosting.usesDomain` and a `domain`, and
542
- * no `env` entry that names a value `uses.names` writes.
532
+ * no `env` entry that names a value `uses.edge` writes.
543
533
  */
544
- function validateEntryNames(entry, hosting, domain) {
545
- const names = entry.uses?.names;
546
- if (names === undefined)
534
+ function validateEntryEdge(entry, hosting, domain) {
535
+ const edge = entry.uses?.edge;
536
+ if (edge === undefined)
547
537
  return null;
548
- const at = `hosting.entries '${entry.key}' uses.names`;
549
- if (names !== true)
538
+ const at = `hosting.entries '${entry.key}' uses.edge`;
539
+ if (edge !== true)
550
540
  return `${at} must be true`;
551
541
  if (!hosting.enabled)
552
542
  return `${at} requires hosting.enabled`;
553
543
  if (!hosting.usesDomain || !domain) {
554
- return `${at} needs hosting.usesDomain and an org domain: names serves only the org's own domain`;
544
+ return `${at} needs hosting.usesDomain and an org domain: edge serves only the org's own domain`;
555
545
  }
556
- const named = Object.keys(entry.env ?? {}).find((name) => NAMES_ENTRY_ENV_NAMES.includes(name));
546
+ const named = Object.keys(entry.env ?? {}).find((name) => EDGE_ENTRY_ENV_NAMES.includes(name));
557
547
  if (named)
558
548
  return `${at} writes ${named}; remove it from env`;
559
549
  return null;
@@ -51,8 +51,10 @@ export interface PlanOrgOptions {
51
51
  now?: () => number;
52
52
  /** #374: the Archon that bills this org (`plan-apply.ts`'s `orgArchonUrl`), written into Boule's env as `ARCHON_URL`. `null` with no platform wired. */
53
53
  archonUrl?: string | null;
54
- /** #318: the executor was built with a `WorkersPort`, read by `planEdgeWorkers`. */
54
+ /** #318: the executor was built with a `WorkersPort`, read by `planWorkers`. */
55
55
  edgeWorkers?: boolean;
56
+ /** The platform's own service hostnames: `edgeReservedLabels` reserves them under the org's domain, and a wildcard Worker route (`planWorkers`) keeps them out. */
57
+ platformHostnames?: readonly string[];
56
58
  }
57
59
  export interface PlanDetail {
58
60
  orgId: OrgId;
package/dist/plan/diff.js CHANGED
@@ -3,10 +3,9 @@ import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
3
3
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
4
4
  import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
5
5
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
6
- import { planEdgeWorkers } from './edge-worker.js';
7
6
  import { planEntryAiKeys } from './entry-ai-key.js';
7
+ import { planEdgeReservedLabels, planEntryEdgeKeys } from './entry-edge-key.js';
8
8
  import { planFilesKeys } from './entry-files-key.js';
9
- import { planEntryNamesKeys, planNamesReservedLabels } from './entry-names-key.js';
10
9
  import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
11
10
  import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
12
11
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
@@ -15,6 +14,7 @@ import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
15
14
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
16
15
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec } from './proxy-labels.js';
17
16
  import { withStepSystem } from './step-system.js';
17
+ import { planWorkers } from './worker.js';
18
18
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
19
19
  export function toPlan(detail) {
20
20
  return {
@@ -206,14 +206,14 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
206
206
  plannedSteps.push(...filesKeys.steps);
207
207
  // F7 (#317): reserved labels before any entry's own key, so a label is
208
208
  // never claimable by a customer in the gap between an entry's key going
209
- // live and its siblings' labels reaching names.
210
- const namesReservedLabels = planNamesReservedLabels(org, recordedIds, platformReadiness);
211
- if (namesReservedLabels.step)
212
- plannedSteps.push(namesReservedLabels.step);
213
- // After the apps -- a names key needs the entry's app recorded, the same
209
+ // live and its siblings' labels reaching edge.
210
+ const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
211
+ if (edgeReservedLabels.step)
212
+ plannedSteps.push(edgeReservedLabels.step);
213
+ // After the apps -- a edge key needs the entry's app recorded, the same
214
214
  // dependency `planEntryAiKeys`/`planFilesKeys` have.
215
- const entryNamesKeys = planEntryNamesKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
216
- plannedSteps.push(...entryNamesKeys.steps);
215
+ const entryEdgeKeys = planEntryEdgeKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
216
+ plannedSteps.push(...entryEdgeKeys.steps);
217
217
  const tunnel = planTunnel(org, byKey, claimed, recordedIds);
218
218
  // #238: a tunnel that is being provisioned waits for an active zone; parking
219
219
  // one never does.
@@ -274,16 +274,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
274
274
  // F8 (#318), after Agora and Boule, not right after the zone: a route
275
275
  // already bound by a hand-made entry, another entry's route, a taken
276
276
  // script name, or no Workers token all throw at apply, and `runSteps`
277
- // stops at the first failure -- planned this early, a failing edge step
277
+ // stops at the first failure -- planned this early, a failing worker step
278
278
  // stopped Agora (and everything after it) from provisioning at all
279
279
  // (#318 re-review). `cloudflareWorker` still resolves the zone by apex
280
280
  // domain the same way `planRateLimit` does, so a deploy (never a
281
281
  // retire, which needs no new zone) still waits for it.
282
- const edgeWorkers = planEdgeWorkers(org, recordedIds, options.edgeWorkers ?? false);
282
+ const edgeWorkers = planWorkers(org, recordedIds, options.edgeWorkers ?? false, options.platformHostnames ?? []);
283
283
  for (const step of edgeWorkers.steps) {
284
284
  if (zoneWaiting && step.action.desired.mode === 'apply') {
285
285
  zoneBlocked.push({
286
- description: `${org.slug}'s ${step.action.key.slice(org.id.length + 1)} edge Worker is blocked, not planned: the zone for ${org.domain} is not active yet`,
286
+ description: `${org.slug}'s ${step.action.key.slice(org.id.length + 1)} Worker is blocked, not planned: the zone for ${org.domain} is not active yet`,
287
287
  key: step.action.key,
288
288
  });
289
289
  }
@@ -312,8 +312,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
312
312
  unmanaged.push(...filesKeys.blocked);
313
313
  unmanaged.push(...entryAiKeys.blocked);
314
314
  unmanaged.push(...edgeWorkers.blocked);
315
- unmanaged.push(...entryNamesKeys.blocked);
316
- unmanaged.push(...namesReservedLabels.blocked);
315
+ unmanaged.push(...entryEdgeKeys.blocked);
316
+ unmanaged.push(...edgeReservedLabels.blocked);
317
317
  // Same for a jobs registration that cannot run (no scheduler wired) or must
318
318
  // not run again (minted, never delivered).
319
319
  if (jobsRegistration.blocked) {
@@ -2214,10 +2214,10 @@ function planTunnel(org, byKey, claimed, recordedIds) {
2214
2214
  // differ from the names now declared. An org that never recorded a list
2215
2215
  // has nothing to compare, so only the domain check above applies to it.
2216
2216
  const published = recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
2217
- const namesChanged = published !== undefined &&
2217
+ const edgeChanged = published !== undefined &&
2218
2218
  org.domain !== null &&
2219
2219
  sortedNames(published.split(',')) !== sortedNames(tunnelDnsHostnames(org, org.domain));
2220
- const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain || namesChanged;
2220
+ const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain || edgeChanged;
2221
2221
  if (!connectorMissing && !dnsStale)
2222
2222
  return null;
2223
2223
  return {
@@ -0,0 +1,79 @@
1
+ import type { DeclaredOrg, HostingEntry } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ import type { OrgPlatformReadiness } from './platform-org.js';
4
+ /**
5
+ * F7 (#317): one 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 declare const entryEdgeUndeliveredScopeKey: (entryKey: string) => string;
27
+ /** The id of a key that was live when its entry stopped declaring `uses.edge`, once handled. */
28
+ export declare const entryEdgeRetiredScopeKey: (entryKey: string) => string;
29
+ export declare 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 declare 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 declare const entryEdgeMintedAtScopeKey: (entryKey: string) => string;
34
+ /** A key with fewer days left than this is surfaced as a blocked warning row (#317 review): still live, but nothing here renews it. */
35
+ export declare const EDGE_ENTRY_KEY_WARN_WINDOW_DAYS = 30;
36
+ /** The reserved labels last sent to the edge service, as that sorted list's own JSON. */
37
+ export declare const EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY = "edge_reserved_labels_applied";
38
+ export declare const entryEdgeKeyName: (org: DeclaredOrg, entry: HostingEntry) => string;
39
+ /**
40
+ * Every label under `org.domain` that foundry provisions or the platform
41
+ * uses, so edge never lets a company claim one: the hostnames foundry routes
42
+ * (`tunnelIngressHostnames`: entries, site, cms, calls, Agora, Boule, share),
43
+ * the names it locks (`lockedHostnames`: also the sign-in host and the
44
+ * platform's service hostnames), each entry's own labels, the mail labels
45
+ * and `www`. The entry's `*` is the space users claim in, so it reserves
46
+ * nothing, and neither does the bare domain.
47
+ *
48
+ * An org without a domain has no hostnames edge sees (`uses.edge` needs a
49
+ * domain), so it reserves `www` only.
50
+ */
51
+ export declare function edgeReservedLabels(org: DeclaredOrg, platformHostnames?: readonly string[]): string[];
52
+ type Blocked = Array<{
53
+ description: string;
54
+ key: string;
55
+ }>;
56
+ /**
57
+ * One step per entry whose edge key is missing, plus one retire step per
58
+ * entry whose `uses.edge` was removed but still has a recorded key.
59
+ * Blocked, not planned (rule 2), while the platform has no edge service
60
+ * wired, the entry's app is not recorded, or a previous mint's delivery
61
+ * failed.
62
+ */
63
+ export declare function planEntryEdgeKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], readiness: OrgPlatformReadiness, now?: number): {
64
+ steps: DraftStep[];
65
+ blocked: Blocked;
66
+ };
67
+ /**
68
+ * The reserved-labels step: resent whenever the org's own label set
69
+ * (`edgeReservedLabels`) differs from what was last sent. While the org
70
+ * uses edge at all (at least one entry with `uses.edge`) that set is
71
+ * `edgeReservedLabels(org)`; once the last entry drops `uses.edge`, this
72
+ * clears whatever was last sent (an empty list) rather than leaving it
73
+ * stuck in names forever -- a one-time step, same as any other change.
74
+ */
75
+ export declare function planEdgeReservedLabels(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, readiness: OrgPlatformReadiness, platformHostnames?: readonly string[]): {
76
+ step: DraftStep | null;
77
+ blocked: Blocked;
78
+ };
79
+ export {};