@wtfalch/foundry 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/bootstrap/declare.js +6 -4
  2. package/dist/index.d.ts +2 -2
  3. package/dist/index.js +1 -1
  4. package/dist/migrations/0043_platform_names.sql +25 -0
  5. package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
  6. package/dist/migrations/0045_names_to_edge.sql +107 -0
  7. package/dist/org.d.ts +74 -3
  8. package/dist/org.js +133 -0
  9. package/dist/plan/chat-matrix.d.ts +0 -1
  10. package/dist/plan/chat-matrix.js +4 -1
  11. package/dist/plan/diff.d.ts +18 -0
  12. package/dist/plan/diff.js +239 -26
  13. package/dist/plan/entry-edge-key.d.ts +79 -0
  14. package/dist/plan/entry-edge-key.js +260 -0
  15. package/dist/plan/executor.d.ts +246 -1
  16. package/dist/plan/executor.js +684 -32
  17. package/dist/plan/index.d.ts +2 -2
  18. package/dist/plan/index.js +1 -1
  19. package/dist/plan/live.d.ts +4 -2
  20. package/dist/plan/login-host.d.ts +12 -1
  21. package/dist/plan/login-host.js +20 -2
  22. package/dist/plan/plan-apply.d.ts +15 -0
  23. package/dist/plan/plan-apply.js +102 -9
  24. package/dist/plan/platform-diff.d.ts +92 -1
  25. package/dist/plan/platform-diff.js +166 -1
  26. package/dist/plan/platform-org.d.ts +29 -1
  27. package/dist/plan/platform-org.js +17 -2
  28. package/dist/plan/platform-providers.d.ts +45 -1
  29. package/dist/plan/platform-providers.js +514 -7
  30. package/dist/plan/step-system.js +1 -0
  31. package/dist/plan/worker.d.ts +134 -0
  32. package/dist/plan/worker.js +239 -0
  33. package/dist/platform.d.ts +22 -3
  34. package/dist/platform.js +6 -0
  35. package/dist/store/postgres.js +15 -1
  36. package/dist/store/schema.d.ts +70 -2
  37. package/dist/store/schema.js +6 -0
  38. package/package.json +1 -1
@@ -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 `edge` 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). `edge` stays off
30
+ * because the edge 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_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, } 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, 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,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;
@@ -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
@@ -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
+ * `validateEntryWorker` 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
+ worker?: WorkerConfig;
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 WorkerRateLimitBinding {
350
+ name: string;
351
+ namespaceId: string;
352
+ limit: number;
353
+ period: 10 | 60;
354
+ }
355
+ /** {@link HostingEntry.worker}'s bindings, one list per binding kind -- only rate limits today. */
356
+ export interface WorkerBindings {
357
+ rateLimits: WorkerRateLimitBinding[];
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"). `validateEntryWorker` 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 WorkerConfig {
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 `planWorkers` 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?: WorkerBindings;
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 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
+ * org `domain`.
449
+ */
450
+ edge?: true;
387
451
  }
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"];
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.edge` writes; `env` may not name them (#317). */
40
+ export const EDGE_ENTRY_ENV_NAMES = ['EDGE_URL', 'EDGE_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 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;
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 worker 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 seenWorkerRoutes = new Set();
151
+ for (const entry of hosting.entries) {
152
+ if (!entry.worker)
153
+ continue;
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
+ }
157
+ seenWorkerRoutes.add(entry.worker.route);
158
+ }
137
159
  const invalidNames = validateEntryHostnames(hosting, names);
138
160
  if (invalidNames)
139
161
  return invalidNames;
@@ -415,6 +437,117 @@ 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 workerRouteZone(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 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
+ /**
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
+ * module's own `DeclaredOrg`/`HostingEntry` imports right back into this
454
+ * one. `workerName`'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 WORKER_NAME_FIXED_LENGTH = 8 + 1 + '-edge'.length;
459
+ const WORKER_NAME_MAX_LENGTH = 63;
460
+ /**
461
+ * `worker`: 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 validateEntryWorker(entry, hosting, domain) {
471
+ const worker = entry.worker;
472
+ if (!worker)
473
+ return null;
474
+ const at = `hosting.entries '${entry.key}' worker`;
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 > 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
+ }
483
+ let artifactUrl;
484
+ try {
485
+ artifactUrl = new URL(worker.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 (worker.artifactUrl.includes('#')) {
501
+ return `${at} artifactUrl must not carry a fragment (refused for the same reason as a query string)`;
502
+ }
503
+ if (!WORKER_SHA256.test(worker.artifactSha256)) {
504
+ return `${at} artifactSha256 must be 64 lowercase hex characters`;
505
+ }
506
+ if (!WORKER_ROUTE.test(worker.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 = workerRouteZone(worker.route);
510
+ if (!domain || zone !== domain) {
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 ?? []) {
516
+ if (!limit.name.trim())
517
+ return `${at} bindings.rateLimits needs a non-empty name`;
518
+ if (!limit.namespaceId.trim())
519
+ return `${at} bindings.rateLimits needs a non-empty namespaceId`;
520
+ if (!(Number.isInteger(limit.limit) && limit.limit > 0)) {
521
+ return `${at} bindings.rateLimits limit must be a positive whole number`;
522
+ }
523
+ if (limit.period !== 10 && limit.period !== 60) {
524
+ return `${at} bindings.rateLimits period must be 10 or 60 (Cloudflare allows no other value)`;
525
+ }
526
+ }
527
+ return null;
528
+ }
529
+ /**
530
+ * `uses.edge`: serves only the org's own domain (edge claims
531
+ * `<label>.<domain>`), so it needs `hosting.usesDomain` and a `domain`, and
532
+ * no `env` entry that names a value `uses.edge` writes.
533
+ */
534
+ function validateEntryEdge(entry, hosting, domain) {
535
+ const edge = entry.uses?.edge;
536
+ if (edge === undefined)
537
+ return null;
538
+ const at = `hosting.entries '${entry.key}' uses.edge`;
539
+ if (edge !== true)
540
+ return `${at} must be true`;
541
+ if (!hosting.enabled)
542
+ return `${at} requires hosting.enabled`;
543
+ if (!hosting.usesDomain || !domain) {
544
+ return `${at} needs hosting.usesDomain and an org domain: edge serves only the org's own domain`;
545
+ }
546
+ const named = Object.keys(entry.env ?? {}).find((name) => EDGE_ENTRY_ENV_NAMES.includes(name));
547
+ if (named)
548
+ return `${at} writes ${named}; remove it from env`;
549
+ return null;
550
+ }
418
551
  function urlHasPassword(value) {
419
552
  try {
420
553
  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,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 `planWorkers`. */
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[];
54
58
  }
55
59
  export interface PlanDetail {
56
60
  orgId: OrgId;
@@ -117,3 +121,17 @@ export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[]
117
121
  export declare const AI_KEY_UNDELIVERED_SCOPE_KEY = "ai_key_undelivered";
118
122
  /** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
119
123
  export declare const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = "jobs_registration_undelivered";
124
+ /**
125
+ * #484: the spend cap foundry applies when an org declares no AI limit
126
+ * (`org.ai.limitMicros === null`) -- Archon creates every company that way,
127
+ * and AI's own `updateBudget` has no uncapped representation
128
+ * (`UpdateBudget.limitMicros` is a required string), so a null limit used to
129
+ * throw `ai:budget` out of every apply. William's Decision, 2026-10-05: 100
130
+ * EUR, in `ai`'s own USD-micros unit (`usdToMicros`,
131
+ * `ai/packages/service/src/money.ts`), billing to revisit the number later.
132
+ * 100 EUR at the ECB reference rate for 2 October 2026 (1.1225 USD/EUR, the
133
+ * most recent published rate as of the decision) is 112.25 USD = 112,250,000
134
+ * micros. A declared numeric limit always wins over this default --
135
+ * {@link effectiveAiLimitMicros} is the one place that substitutes it.
136
+ */
137
+ export declare const DEFAULT_AI_LIMIT_MICROS = "112250000";