@wtfalch/foundry 0.17.0 → 0.17.1

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,10 +23,12 @@ 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` is enabled, like the other steps: a
27
- * service whose dependencies the host did not supply is reported blocked, not
28
- * planned. `sfu` stays off because it needs a held SSH key and host
29
- * fingerprint, a separate human step (issue 170).
26
+ * Every platform service except `sfu` and `names` is enabled, like the other
27
+ * steps: a service whose dependencies the host did not supply is reported
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
31
+ * and sets its hostname before enabling it (#317's own "Human steps").
30
32
  */
31
33
  const SERVICES_ENABLED_BY_DEFAULT = new Set([
32
34
  'auth',
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, 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_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';
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, } from './plan/index.js';
38
+ export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformNamesDeps, PlatformNamesRuntime, } 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, 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_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';
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,25 @@
1
+ -- @wtfalch/foundry: #317 (F7) -- the names service, which mints an entry's
2
+ -- own names key and holds the Cloudflare token `<username>.<org domain>`
3
+ -- claims use, added to the declared platform the same way every other
4
+ -- shared service here was (#165). Mirrors `src/store/schema.ts`'s
5
+ -- `foundryDeclaredPlatform` table additions.
6
+ --
7
+ -- names_hostname has no static default the way every other service's
8
+ -- column did at table creation -- #317 is the first service added after
9
+ -- the table already carries its one live row, so a column that must not be
10
+ -- empty (src/platform.ts's `validatePlatform`) cannot default to ''.
11
+ -- Backfilled from this row's own apex before the column is required, so an
12
+ -- existing platform keeps validating unchanged, at the default hostname an
13
+ -- operator can still override afterwards.
14
+
15
+ alter table foundry_declared_platform
16
+ add column if not exists names_enabled boolean not null default false,
17
+ add column if not exists names_hostname text,
18
+ add column if not exists names_git_ref text not null default '';
19
+
20
+ update foundry_declared_platform
21
+ set names_hostname = 'names.' || apex
22
+ where names_hostname is null;
23
+
24
+ alter table foundry_declared_platform
25
+ alter column names_hostname set not null;
@@ -0,0 +1,11 @@
1
+ -- @wtfalch/foundry: #491 -- the Lettermint project id of the platform's own
2
+ -- org (an org whose mail domain equals the platform apex, e.g. Company
3
+ -- Included on companyincluded.com), so that org shares the platform's
4
+ -- existing Lettermint project instead of getting a second `<slug>-app` one
5
+ -- of its own. Declared, never discovered: null until an operator sets it
6
+ -- (src/platform.ts's `validatePlatform` refuses a present-but-blank value,
7
+ -- not this column). Mirrors src/store/schema.ts's `foundryDeclaredPlatform`
8
+ -- table addition.
9
+
10
+ alter table foundry_declared_platform
11
+ add column if not exists mail_lettermint_project_id text;
package/dist/org.d.ts CHANGED
@@ -331,6 +331,62 @@ export interface HostingEntry {
331
331
  env: Record<string, EnvValue>;
332
332
  /** What the entry signs in through. Absent means it uses nothing. */
333
333
  uses?: HostingEntryUses;
334
+ /**
335
+ * F8: a Cloudflare Worker foundry deploys from this entry's own repo,
336
+ * beside the app, on the org's own zone. Only our own repos may use this
337
+ * (#318: outside customers need Workers for Platforms, not built yet), so
338
+ * `validateEntryEdge` refuses it unless `repo` is set (every non-null
339
+ * `repo` already matches `wtfalch/...`, {@link REPO}) and the route's zone
340
+ * is the org's own domain.
341
+ */
342
+ edge?: EdgeConfig;
343
+ }
344
+ /**
345
+ * One Cloudflare rate limiting binding on a Worker (#318) -- Cloudflare's own
346
+ * "simple" shape: a fixed number of requests per period, where `period` is
347
+ * the only two values Cloudflare's rate limiting binding accepts.
348
+ */
349
+ export interface EdgeRateLimitBinding {
350
+ name: string;
351
+ namespaceId: string;
352
+ limit: number;
353
+ period: 10 | 60;
354
+ }
355
+ /** {@link HostingEntry.edge}'s bindings, one list per binding kind -- only rate limits today. */
356
+ export interface EdgeBindings {
357
+ rateLimits: EdgeRateLimitBinding[];
358
+ }
359
+ /**
360
+ * A plain Worker deployed beside a hosting entry (#318), named by one route
361
+ * on the org's own zone. `artifactUrl`/`artifactSha256` name a *built*
362
+ * module -- the entry's own repo builds and publishes it (a release asset,
363
+ * typically), foundry only fetches and verifies it against the declared
364
+ * digest before uploading (estate DECISIONS.md, 2026-10-04, "offline.tf
365
+ * Worker setup": "The app's automated build process builds the bundle
366
+ * outside Foundry; Foundry consumes and deploys the artifact through the
367
+ * provider boundary... No manual build step and no source checkout/bundling
368
+ * in Foundry"). `validateEntryEdge` refuses anything that is not an
369
+ * `https://` URL -- a bare path or relative file name is exactly the shape
370
+ * this decision rules out.
371
+ */
372
+ export interface EdgeConfig {
373
+ /** Where foundry fetches the built module from, behind `WorkersPort` -- never checked out or bundled here. */
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. */
376
+ artifactSha256: string;
377
+ /**
378
+ * One route per entry today. A wildcard route (`*.<domain>/*`) matches
379
+ * every subdomain, which also captures every other hostname the org
380
+ * serves under that domain -- its own app included, e.g. `app.<domain>`
381
+ * -- since Cloudflare routes the most specific match but a Worker route
382
+ * has no path-based exclusion of its own; declare it carefully so it
383
+ * does not shadow the app. The apex itself (`<domain>`, no subdomain
384
+ * label) is never matched by a wildcard route -- not even partially --
385
+ * so an org that wants the apex reachable through a Worker declares a
386
+ * separate entry with that exact route; there is no exclusion list.
387
+ */
388
+ route: string;
389
+ bindings?: EdgeBindings;
334
390
  }
335
391
  /**
336
392
  * Sign-in for one entry: one ZITADEL client and one broker entry, signing in
@@ -384,7 +440,17 @@ export interface HostingEntryUses {
384
440
  auth?: EntryAuth;
385
441
  ai?: EntryAi;
386
442
  files?: EntryFiles;
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
448
+ * org `domain`.
449
+ */
450
+ names?: true;
387
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"];
388
454
  /**
389
455
  * A plain string is pushed as it is, so `validateHosting` refuses one that
390
456
  * looks like a secret. `{ from }` names a value the plan already mints.
@@ -526,9 +592,14 @@ export interface StorageConfig {
526
592
  * this is the declared value `apply` pushes onto it.
527
593
  *
528
594
  * A bigint-safe decimal string of a non-negative integer, never converted to
529
- * a number. `null` means uncapped. Spend itself (usage, money spent) is
530
- * never declared (#27 "Done when"): it is a runtime fact read live from AI's
531
- * usage ledger, shown beside this limit, never stored here.
595
+ * a number. `null` means no limit is declared -- not uncapped: `apply`
596
+ * (`diff.ts`'s `effectiveAiLimitMicros`) substitutes a default spend cap,
597
+ * `DEFAULT_AI_LIMIT_MICROS`, in that case (#484; Archon creates every
598
+ * company this way, and AI's own budget API has no uncapped
599
+ * representation). A declared numeric limit always wins over the default.
600
+ * Spend itself (usage, money spent) is never declared (#27 "Done when"): it
601
+ * is a runtime fact read live from AI's usage ledger, shown beside this
602
+ * limit, never stored here.
532
603
  */
533
604
  export interface AiConfig {
534
605
  limitMicros: string | null;
package/dist/org.js CHANGED
@@ -36,6 +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
41
  /** The org's `app` entry: today's one app. */
40
42
  export function appEntry(hosting) {
41
43
  return hosting.entries.find((entry) => entry.key === APP_ENTRY_KEY);
@@ -130,10 +132,30 @@ export function validateHosting(hosting, names) {
130
132
  const invalidFiles = validateEntryFiles(entry, hosting, names?.domain);
131
133
  if (invalidFiles)
132
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;
133
141
  }
134
142
  if (hosting.entries.filter((entry) => entry.uses?.ai?.webhookPath !== undefined).length > 1) {
135
143
  return 'at most one hosting entry may set uses.ai webhookPath: ai keeps one webhook per organisation';
136
144
  }
145
+ // #318 re-review: two entries declaring the same edge route both throw at
146
+ // apply (`cloudflareWorker` refuses a route already bound to a Worker
147
+ // that is not its own), one of which stops Agora and everything planned
148
+ // after it provisioning at all -- refused here instead, before an apply
149
+ // ever reaches it.
150
+ const seenEdgeRoutes = new Set();
151
+ for (const entry of hosting.entries) {
152
+ if (!entry.edge)
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`;
156
+ }
157
+ seenEdgeRoutes.add(entry.edge.route);
158
+ }
137
159
  const invalidNames = validateEntryHostnames(hosting, names);
138
160
  if (invalidNames)
139
161
  return invalidNames;
@@ -415,6 +437,127 @@ function validateEntryFiles(entry, hosting, domain) {
415
437
  return `${at} writes ${named}; remove it from env`;
416
438
  return null;
417
439
  }
440
+ /** A route pattern's zone apex: the host part before the first `/`, with one leading `*.` label stripped. */
441
+ function edgeRouteZone(route) {
442
+ const slash = route.indexOf('/');
443
+ if (slash < 1)
444
+ return null;
445
+ const host = route.slice(0, slash);
446
+ return host.startsWith('*.') ? host.slice(2) : host;
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}$/;
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
453
+ * module's own `DeclaredOrg`/`HostingEntry` imports right back into this
454
+ * one. `edgeWorkerName`'s shape is `${8-hex-char hash}-${entry.key}-edge`;
455
+ * the hash and the `-edge` suffix are fixed length, so the entry key's own
456
+ * budget against Cloudflare's 63-character Worker name limit is fixed too.
457
+ */
458
+ const EDGE_WORKER_NAME_FIXED_LENGTH = 8 + 1 + '-edge'.length;
459
+ const EDGE_WORKER_NAME_MAX_LENGTH = 63;
460
+ /**
461
+ * `edge`: the entry's own repo (never the shared standard app -- entries
462
+ * with `repo: null` have no Worker source of their own), an `https://`
463
+ * artifact URL with a lowercase-hex sha256 (never a bare path or relative
464
+ * file -- estate DECISIONS.md, 2026-10-04, rules out any checkout or
465
+ * bundling inside foundry), a route whose zone is the org's own domain
466
+ * (#318: our own projects only, never a customer's zone), and a rate limit
467
+ * binding's `period` restricted to the two values Cloudflare's simple rate
468
+ * limiting binding accepts.
469
+ */
470
+ function validateEntryEdge(entry, hosting, domain, locked) {
471
+ const edge = entry.edge;
472
+ if (!edge)
473
+ return null;
474
+ const at = `hosting.entries '${entry.key}' edge`;
475
+ if (!hosting.enabled)
476
+ return `${at} requires hosting.enabled`;
477
+ if (entry.repo === null) {
478
+ return `${at} needs its own repo (repo: null is the shared standard app, not a Worker source)`;
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`;
482
+ }
483
+ let artifactUrl;
484
+ try {
485
+ artifactUrl = new URL(edge.artifactUrl);
486
+ }
487
+ catch {
488
+ return `${at} artifactUrl must be an absolute URL, not a bare path or relative file`;
489
+ }
490
+ if (artifactUrl.protocol !== 'https:') {
491
+ return `${at} artifactUrl must be an https:// URL`;
492
+ }
493
+ if (artifactUrl.username || artifactUrl.password) {
494
+ return `${at} artifactUrl must not carry userinfo (a signed or credentialed URL ends up stored verbatim in recorded scope ids)`;
495
+ }
496
+ if (artifactUrl.search) {
497
+ return `${at} artifactUrl must not carry a query string (a signed or credentialed URL ends up stored verbatim in recorded scope ids)`;
498
+ }
499
+ // `URL.hash` is '' for a bare trailing `#`, so the raw string is checked.
500
+ if (edge.artifactUrl.includes('#')) {
501
+ return `${at} artifactUrl must not carry a fragment (refused for the same reason as a query string)`;
502
+ }
503
+ if (!EDGE_SHA256.test(edge.artifactSha256)) {
504
+ return `${at} artifactSha256 must be 64 lowercase hex characters`;
505
+ }
506
+ if (!EDGE_ROUTE.test(edge.route)) {
507
+ return `${at} route must be a hostname (an optional leading '*.' label, then at least two more labels) and a path, joined by '/'`;
508
+ }
509
+ const zone = edgeRouteZone(edge.route);
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 ?? []) {
526
+ if (!limit.name.trim())
527
+ return `${at} bindings.rateLimits needs a non-empty name`;
528
+ if (!limit.namespaceId.trim())
529
+ return `${at} bindings.rateLimits needs a non-empty namespaceId`;
530
+ if (!(Number.isInteger(limit.limit) && limit.limit > 0)) {
531
+ return `${at} bindings.rateLimits limit must be a positive whole number`;
532
+ }
533
+ if (limit.period !== 10 && limit.period !== 60) {
534
+ return `${at} bindings.rateLimits period must be 10 or 60 (Cloudflare allows no other value)`;
535
+ }
536
+ }
537
+ return null;
538
+ }
539
+ /**
540
+ * `uses.names`: serves only the org's own domain (names claims
541
+ * `<label>.<domain>`), so it needs `hosting.usesDomain` and a `domain`, and
542
+ * no `env` entry that names a value `uses.names` writes.
543
+ */
544
+ function validateEntryNames(entry, hosting, domain) {
545
+ const names = entry.uses?.names;
546
+ if (names === undefined)
547
+ return null;
548
+ const at = `hosting.entries '${entry.key}' uses.names`;
549
+ if (names !== true)
550
+ return `${at} must be true`;
551
+ if (!hosting.enabled)
552
+ return `${at} requires hosting.enabled`;
553
+ if (!hosting.usesDomain || !domain) {
554
+ return `${at} needs hosting.usesDomain and an org domain: names serves only the org's own domain`;
555
+ }
556
+ const named = Object.keys(entry.env ?? {}).find((name) => NAMES_ENTRY_ENV_NAMES.includes(name));
557
+ if (named)
558
+ return `${at} writes ${named}; remove it from env`;
559
+ return null;
560
+ }
418
561
  function urlHasPassword(value) {
419
562
  try {
420
563
  return new URL(value).password !== '';
@@ -90,7 +90,6 @@ export interface ChatServiceEnvInput {
90
90
  agoraHost: string;
91
91
  masClientId: string;
92
92
  masClientSecret: string;
93
- databaseUrl: string;
94
93
  }
95
94
  /** Every variable `chat/apps/chat-service/src/config.ts` requires, plus the internal Synapse address and port. */
96
95
  export declare function chatServiceEnvs(input: ChatServiceEnvInput): Array<{
@@ -195,7 +195,10 @@ export function chatServiceEnvs(input) {
195
195
  { key: 'MAS_URL', value: upstreams.mas },
196
196
  { key: 'MAS_CLIENT_ID', value: input.masClientId },
197
197
  { key: 'MAS_CLIENT_SECRET', value: input.masClientSecret },
198
- { key: 'DATABASE_URL', value: input.databaseUrl },
198
+ // DATABASE_URL_OWNER/DATABASE_URL are pushed by executor.ts's
199
+ // pushDatabaseUrl instead, not here (the same split coolifyApp and
200
+ // cmsSite follow, #239) -- that call mints a distinct <dbName>_rt
201
+ // runtime role and never overwrites an already-minted DATABASE_URL.
199
202
  { key: 'CHAT_ALLOWED_ORIGINS', value: `https://${input.agoraHost}` },
200
203
  { key: 'PORT', value: CHAT_SERVICE_PORT },
201
204
  ];
@@ -51,6 +51,8 @@ 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`. */
55
+ edgeWorkers?: boolean;
54
56
  }
55
57
  export interface PlanDetail {
56
58
  orgId: OrgId;
@@ -117,3 +119,17 @@ export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[]
117
119
  export declare const AI_KEY_UNDELIVERED_SCOPE_KEY = "ai_key_undelivered";
118
120
  /** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
119
121
  export declare const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = "jobs_registration_undelivered";
122
+ /**
123
+ * #484: the spend cap foundry applies when an org declares no AI limit
124
+ * (`org.ai.limitMicros === null`) -- Archon creates every company that way,
125
+ * and AI's own `updateBudget` has no uncapped representation
126
+ * (`UpdateBudget.limitMicros` is a required string), so a null limit used to
127
+ * throw `ai:budget` out of every apply. William's Decision, 2026-10-05: 100
128
+ * EUR, in `ai`'s own USD-micros unit (`usdToMicros`,
129
+ * `ai/packages/service/src/money.ts`), billing to revisit the number later.
130
+ * 100 EUR at the ECB reference rate for 2 October 2026 (1.1225 USD/EUR, the
131
+ * most recent published rate as of the decision) is 112.25 USD = 112,250,000
132
+ * micros. A declared numeric limit always wins over this default --
133
+ * {@link effectiveAiLimitMicros} is the one place that substitutes it.
134
+ */
135
+ export declare const DEFAULT_AI_LIMIT_MICROS = "112250000";