@wtfalch/foundry 0.17.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/bootstrap/declare.d.ts +11 -2
  2. package/dist/bootstrap/declare.js +20 -11
  3. package/dist/index.d.ts +4 -2
  4. package/dist/index.js +2 -1
  5. package/dist/migrations/0045_names_to_edge.sql +107 -0
  6. package/dist/org.d.ts +17 -17
  7. package/dist/org.js +48 -58
  8. package/dist/plan/agora.d.ts +25 -0
  9. package/dist/plan/agora.js +25 -0
  10. package/dist/plan/boule.d.ts +8 -0
  11. package/dist/plan/boule.js +8 -0
  12. package/dist/plan/diff.d.ts +3 -1
  13. package/dist/plan/diff.js +115 -52
  14. package/dist/plan/entry-edge-key.d.ts +79 -0
  15. package/dist/plan/{entry-names-key.js → entry-edge-key.js} +90 -66
  16. package/dist/plan/executor.d.ts +87 -42
  17. package/dist/plan/executor.js +277 -120
  18. package/dist/plan/index.d.ts +2 -2
  19. package/dist/plan/index.js +1 -1
  20. package/dist/plan/live.d.ts +3 -3
  21. package/dist/plan/login-host.d.ts +15 -11
  22. package/dist/plan/login-host.js +16 -16
  23. package/dist/plan/plan-apply.d.ts +6 -5
  24. package/dist/plan/plan-apply.js +40 -38
  25. package/dist/plan/platform-diff.d.ts +22 -22
  26. package/dist/plan/platform-diff.js +45 -45
  27. package/dist/plan/platform-org-ports.d.ts +1 -0
  28. package/dist/plan/platform-org-ports.js +3 -0
  29. package/dist/plan/platform-org.d.ts +8 -8
  30. package/dist/plan/platform-org.js +7 -7
  31. package/dist/plan/platform-providers.d.ts +17 -16
  32. package/dist/plan/platform-providers.js +136 -118
  33. package/dist/plan/providers-readers.d.ts +34 -11
  34. package/dist/plan/providers-readers.js +72 -15
  35. package/dist/plan/step-system.js +1 -1
  36. package/dist/plan/{edge-worker.d.ts → worker.d.ts} +53 -33
  37. package/dist/plan/{edge-worker.js → worker.js} +92 -54
  38. package/dist/platform.d.ts +3 -3
  39. package/dist/platform.js +1 -1
  40. package/dist/store/postgres.js +7 -7
  41. package/dist/store/schema.d.ts +6 -6
  42. package/dist/store/schema.js +4 -4
  43. package/package.json +1 -1
  44. package/dist/plan/entry-names-key.d.ts +0 -73
@@ -37,8 +37,17 @@ export interface BootstrapOwner {
37
37
  */
38
38
  export declare function ownerIdentity(owner: BootstrapOwner): OwnerIdentity;
39
39
  /**
40
- * Company Included's declared org: the platform's owner, running no app or
41
- * mail of its own. Chat is on, like every org's.
40
+ * Company Included's declared org: the platform's owner, tier 3 on the
41
+ * platform's own apex from the start (#512) -- it runs on that domain with
42
+ * hosted mail, not a level-1 org whose domain gets set by hand afterward.
43
+ * `issuer`/`calls`/`site` go on the domain too, the same tier-3 defaults
44
+ * archon#388 gives every company declared there. `calls` and `site` stay
45
+ * disabled (calls is not in v1; no site is declared), so `usesDomain` is
46
+ * inert on both until one is switched on. Chat stays off the domain: its
47
+ * re-key onto a per-org homeserver is not built yet (#463). The org's own
48
+ * sign-in still never collides with the shared ZITADEL instance's host --
49
+ * `loginHostnameFor` (#488) falls back to `<slug>-auth.oikos.host` whenever
50
+ * `auth.<domain>` would equal the platform's own auth hostname.
42
51
  */
43
52
  export declare function declaredCompanyOrg(company: BootstrapCompany, owner: BootstrapOwner): DeclaredOrg;
44
53
  /**
@@ -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([
@@ -55,25 +55,34 @@ export function ownerIdentity(owner) {
55
55
  return identity;
56
56
  }
57
57
  /**
58
- * Company Included's declared org: the platform's owner, running no app or
59
- * mail of its own. Chat is on, like every org's.
58
+ * Company Included's declared org: the platform's owner, tier 3 on the
59
+ * platform's own apex from the start (#512) -- it runs on that domain with
60
+ * hosted mail, not a level-1 org whose domain gets set by hand afterward.
61
+ * `issuer`/`calls`/`site` go on the domain too, the same tier-3 defaults
62
+ * archon#388 gives every company declared there. `calls` and `site` stay
63
+ * disabled (calls is not in v1; no site is declared), so `usesDomain` is
64
+ * inert on both until one is switched on. Chat stays off the domain: its
65
+ * re-key onto a per-org homeserver is not built yet (#463). The org's own
66
+ * sign-in still never collides with the shared ZITADEL instance's host --
67
+ * `loginHostnameFor` (#488) falls back to `<slug>-auth.oikos.host` whenever
68
+ * `auth.<domain>` would equal the platform's own auth hostname.
60
69
  */
61
70
  export function declaredCompanyOrg(company, owner) {
62
71
  return {
63
72
  id: COMPANY_INCLUDED_ORG_ID,
64
73
  slug: company.slug,
65
74
  displayName: company.name,
66
- domain: null,
75
+ domain: company.apex,
67
76
  mail: {
68
- mode: 'off',
69
- usesDomain: false,
77
+ mode: 'hosted',
78
+ usesDomain: true,
70
79
  ownerMailboxAddress: null,
71
80
  noReplyLocalPart: 'no-reply',
72
81
  },
73
82
  chat: { enabled: true, usesDomain: false },
74
- calls: { enabled: false, usesDomain: false },
75
- issuer: { usesDomain: false },
76
- site: { enabled: false, usesDomain: false },
83
+ calls: { enabled: false, usesDomain: true },
84
+ issuer: { usesDomain: true },
85
+ site: { enabled: false, usesDomain: true },
77
86
  hosting: {
78
87
  enabled: false,
79
88
  usesDomain: false,
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';
@@ -28,6 +28,8 @@ export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
28
28
  export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, LoginHostBinding, LoginHostBindingsPort, } from './plan/index.js';
29
29
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
30
30
  export { HttpJobsSchedulerClient } from './plan/index.js';
31
+ export type { ScopeResolver, RecordedIds } from './plan/index.js';
32
+ export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
31
33
  export type { ZitadelListAppsRawApp } from './plan/index.js';
32
34
  export { adaptZitadelListApps } from './plan/index.js';
33
35
  export type { MailIdentityApp, MailIdentityAudienceCheck } from './plan/index.js';
@@ -35,7 +37,7 @@ export { checkMailIdentityAudience } from './plan/index.js';
35
37
  export type { MailAllowlistApp } from './plan/index.js';
36
38
  export { checkMailAppsAllowlist } from './plan/index.js';
37
39
  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';
40
+ export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformEdgeRuntime, } from './plan/index.js';
39
41
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './plan/index.js';
40
42
  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
43
  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,11 +12,12 @@ 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';
19
19
  export { HttpJobsSchedulerClient } from './plan/index.js';
20
+ export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
20
21
  export { adaptZitadelListApps } from './plan/index.js';
21
22
  export { checkMailIdentityAudience } from './plan/index.js';
22
23
  export { checkMailAppsAllowlist } 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;
@@ -110,6 +110,18 @@ export declare function agoraHostMissing(host: AgoraHostValues): string[];
110
110
  export declare function agoraHostnameFor(org: DeclaredOrg): string;
111
111
  /** The port `calls/packages/service/Dockerfile` documents (`EXPOSE 8080`); `config.mjs`'s `PORT` has no default, so the executor pushes it. */
112
112
  export declare const CALLS_SERVICE_PORT = "8080";
113
+ /**
114
+ * The port `wtfalch/agora`'s `apps/agora/Dockerfile` serves Caddy on
115
+ * (`EXPOSE 8080`). Coolify's github-app build never reads a Dockerfile's
116
+ * `EXPOSE` on its own, so without this passed as `ports_exposes` Traefik is
117
+ * left routing to a port nothing listens on and Agora answers 502 behind the
118
+ * tunnel (#506, seen live on Company Included, 2026-10-05). `executor.ts`'s
119
+ * `coolifyAgora` sets it at create time and fixes it on a found app whose
120
+ * live value differs; `diff.ts`'s `planAgora` plans that fix the same way it
121
+ * plans an env difference, by comparing this constant against the live
122
+ * app's own `ports_exposes` (`providers-readers.ts`'s `withAgoraPortConfig`).
123
+ */
124
+ export declare const AGORA_APP_PORTS_EXPOSES = "8080";
113
125
  /**
114
126
  * Recorded by the executor once the calls service app carries its fixed
115
127
  * internal name (`callsServiceInternalName`). `desiredAgoraEnv` waits for it,
@@ -196,6 +208,19 @@ export declare function agoraEnvDrift(desired: Readonly<Record<string, string>>,
196
208
  * against (#442's own probe: "one env write, zero restarts, zero deploys").
197
209
  */
198
210
  export declare const AGORA_ACTIVATED_ENV_SCOPE_KEY = "coolify_agora_activated_env";
211
+ /**
212
+ * #504: recorded once `coolifyAgora` has actually called `deploy` on this
213
+ * org's Agora app -- `createGithubAppApplication` never sets
214
+ * `instant_deploy`, and Coolify defaults it to false, so a freshly created
215
+ * app (or one found by name that a previous apply created but crashed
216
+ * before deploying) sat at `exited:unhealthy` with zero deployments
217
+ * forever: nothing ever deployed it, and `restart` cannot start a container
218
+ * that was never built. Absent means "never deployed", regardless of
219
+ * whether the app is found live this run or created fresh -- unlike
220
+ * {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}, this never changes value once set,
221
+ * so a later env change restarts rather than deploys again.
222
+ */
223
+ export declare const AGORA_DEPLOYED_SCOPE_KEY = "coolify_agora_deployed";
199
224
  /**
200
225
  * A stable digest of `env`, for comparison against what was last recorded
201
226
  * under {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}. `AGORA_CONFIG` is compared by
@@ -118,6 +118,18 @@ export function agoraHostnameFor(org) {
118
118
  }
119
119
  /** The port `calls/packages/service/Dockerfile` documents (`EXPOSE 8080`); `config.mjs`'s `PORT` has no default, so the executor pushes it. */
120
120
  export const CALLS_SERVICE_PORT = '8080';
121
+ /**
122
+ * The port `wtfalch/agora`'s `apps/agora/Dockerfile` serves Caddy on
123
+ * (`EXPOSE 8080`). Coolify's github-app build never reads a Dockerfile's
124
+ * `EXPOSE` on its own, so without this passed as `ports_exposes` Traefik is
125
+ * left routing to a port nothing listens on and Agora answers 502 behind the
126
+ * tunnel (#506, seen live on Company Included, 2026-10-05). `executor.ts`'s
127
+ * `coolifyAgora` sets it at create time and fixes it on a found app whose
128
+ * live value differs; `diff.ts`'s `planAgora` plans that fix the same way it
129
+ * plans an env difference, by comparing this constant against the live
130
+ * app's own `ports_exposes` (`providers-readers.ts`'s `withAgoraPortConfig`).
131
+ */
132
+ export const AGORA_APP_PORTS_EXPOSES = '8080';
121
133
  /**
122
134
  * Recorded by the executor once the calls service app carries its fixed
123
135
  * internal name (`callsServiceInternalName`). `desiredAgoraEnv` waits for it,
@@ -305,6 +317,19 @@ export function agoraEnvDrift(desired, live) {
305
317
  * against (#442's own probe: "one env write, zero restarts, zero deploys").
306
318
  */
307
319
  export const AGORA_ACTIVATED_ENV_SCOPE_KEY = 'coolify_agora_activated_env';
320
+ /**
321
+ * #504: recorded once `coolifyAgora` has actually called `deploy` on this
322
+ * org's Agora app -- `createGithubAppApplication` never sets
323
+ * `instant_deploy`, and Coolify defaults it to false, so a freshly created
324
+ * app (or one found by name that a previous apply created but crashed
325
+ * before deploying) sat at `exited:unhealthy` with zero deployments
326
+ * forever: nothing ever deployed it, and `restart` cannot start a container
327
+ * that was never built. Absent means "never deployed", regardless of
328
+ * whether the app is found live this run or created fresh -- unlike
329
+ * {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}, this never changes value once set,
330
+ * so a later env change restarts rather than deploys again.
331
+ */
332
+ export const AGORA_DEPLOYED_SCOPE_KEY = 'coolify_agora_deployed';
308
333
  /**
309
334
  * A stable digest of `env`, for comparison against what was last recorded
310
335
  * under {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}. `AGORA_CONFIG` is compared by
@@ -20,6 +20,14 @@ export declare const BOULE_CLIENT_ID_SCOPE_KEY = "boule_client_id";
20
20
  * scope id, like the cms and calls databases.
21
21
  */
22
22
  export declare const BOULE_DATABASE_SCOPE_KEY = "coolify_boule_database";
23
+ /**
24
+ * #504: the same marker `agora.ts`'s `AGORA_DEPLOYED_SCOPE_KEY` is, for
25
+ * Boule -- recorded once `coolifyBoule` has actually called `deploy` on this
26
+ * org's Boule app. See that constant's own doc comment for why: Coolify
27
+ * defaults `instant_deploy` to false, so without this a freshly created app
28
+ * sat built but never started, and `restart` cannot fix that.
29
+ */
30
+ export declare const BOULE_DEPLOYED_SCOPE_KEY = "coolify_boule_deployed";
23
31
  /**
24
32
  * `boule.<domain>` when the org has a domain, `<slug>-boule.oikos.host`
25
33
  * otherwise (the tier rule of boule#128 and foundry#147; `../slug.ts`'s
@@ -19,6 +19,14 @@ export const BOULE_CLIENT_ID_SCOPE_KEY = 'boule_client_id';
19
19
  * scope id, like the cms and calls databases.
20
20
  */
21
21
  export const BOULE_DATABASE_SCOPE_KEY = 'coolify_boule_database';
22
+ /**
23
+ * #504: the same marker `agora.ts`'s `AGORA_DEPLOYED_SCOPE_KEY` is, for
24
+ * Boule -- recorded once `coolifyBoule` has actually called `deploy` on this
25
+ * org's Boule app. See that constant's own doc comment for why: Coolify
26
+ * defaults `instant_deploy` to false, so without this a freshly created app
27
+ * sat built but never started, and `restart` cannot fix that.
28
+ */
29
+ export const BOULE_DEPLOYED_SCOPE_KEY = 'coolify_boule_deployed';
22
30
  /**
23
31
  * `boule.<domain>` when the org has a domain, `<slug>-boule.oikos.host`
24
32
  * otherwise (the tier rule of boule#128 and foundry#147; `../slug.ts`'s