@wtfalch/foundry 0.32.0 → 0.34.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 (74) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +18 -1
  2. package/dist/bootstrap/bootstrap.js +167 -28
  3. package/dist/bootstrap/declare.d.ts +5 -1
  4. package/dist/bootstrap/declare.js +3 -1
  5. package/dist/bootstrap/index.d.ts +1 -1
  6. package/dist/bootstrap/versions.d.ts +4 -0
  7. package/dist/calls-sfu.d.ts +10 -0
  8. package/dist/calls-sfu.js +41 -0
  9. package/dist/index.d.ts +18 -8
  10. package/dist/index.js +8 -3
  11. package/dist/migrations/0051_org_services.sql +13 -0
  12. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  13. package/dist/org.d.ts +24 -2
  14. package/dist/org.js +42 -0
  15. package/dist/plan/boule.d.ts +24 -1
  16. package/dist/plan/boule.js +29 -1
  17. package/dist/plan/calls-matrix.d.ts +9 -0
  18. package/dist/plan/calls-matrix.js +17 -0
  19. package/dist/plan/cloudflared-connector.d.ts +7 -1
  20. package/dist/plan/cloudflared-connector.js +14 -1
  21. package/dist/plan/diff.d.ts +32 -1
  22. package/dist/plan/diff.js +383 -26
  23. package/dist/plan/executor.d.ts +177 -7
  24. package/dist/plan/executor.js +738 -51
  25. package/dist/plan/health-check-settings.d.ts +28 -0
  26. package/dist/plan/health-check-settings.js +36 -0
  27. package/dist/plan/health-gaps.d.ts +33 -0
  28. package/dist/plan/health-gaps.js +89 -0
  29. package/dist/plan/health-targets.js +1 -1
  30. package/dist/plan/held-key-refs.d.ts +10 -0
  31. package/dist/plan/held-key-refs.js +10 -0
  32. package/dist/plan/index.d.ts +16 -6
  33. package/dist/plan/index.js +8 -3
  34. package/dist/plan/live.d.ts +5 -2
  35. package/dist/plan/live.js +15 -2
  36. package/dist/plan/locked-hostnames.d.ts +5 -2
  37. package/dist/plan/locked-hostnames.js +9 -1
  38. package/dist/plan/login-host.d.ts +62 -2
  39. package/dist/plan/login-host.js +104 -1
  40. package/dist/plan/mail-relay.d.ts +120 -0
  41. package/dist/plan/mail-relay.js +323 -0
  42. package/dist/plan/org-names.d.ts +24 -0
  43. package/dist/plan/org-names.js +52 -0
  44. package/dist/plan/own-diff.d.ts +169 -0
  45. package/dist/plan/own-diff.js +432 -0
  46. package/dist/plan/own-dns.d.ts +6 -0
  47. package/dist/plan/own-dns.js +15 -0
  48. package/dist/plan/own-limits.d.ts +22 -0
  49. package/dist/plan/own-limits.js +101 -0
  50. package/dist/plan/own-services.d.ts +193 -0
  51. package/dist/plan/own-services.js +167 -0
  52. package/dist/plan/own-sfu.d.ts +69 -0
  53. package/dist/plan/own-sfu.js +255 -0
  54. package/dist/plan/plan-apply.d.ts +101 -7
  55. package/dist/plan/plan-apply.js +216 -12
  56. package/dist/plan/platform-diff.d.ts +61 -12
  57. package/dist/plan/platform-diff.js +140 -34
  58. package/dist/plan/platform-org.d.ts +36 -0
  59. package/dist/plan/platform-org.js +96 -2
  60. package/dist/plan/platform-providers.d.ts +407 -12
  61. package/dist/plan/platform-providers.js +947 -422
  62. package/dist/plan/sign-in-org.d.ts +99 -0
  63. package/dist/plan/sign-in-org.js +151 -0
  64. package/dist/plan/zitadel-login-policy.js +1 -1
  65. package/dist/plan/zitadel-system-api.d.ts +22 -1
  66. package/dist/plan/zitadel-system-api.js +49 -8
  67. package/dist/plan.d.ts +18 -0
  68. package/dist/platform.d.ts +8 -0
  69. package/dist/slug.js +1 -0
  70. package/dist/store/postgres.js +51 -2
  71. package/dist/store/schema.d.ts +51 -0
  72. package/dist/store/schema.js +5 -0
  73. package/package.json +1 -1
  74. package/versions.json +3 -1
package/dist/org.d.ts CHANGED
@@ -548,6 +548,22 @@ export declare function validateSite(org: DeclaredOrg): string | null;
548
548
  * reason to pin at all.
549
549
  */
550
550
  export declare function validateCalls(org: DeclaredOrg): string | null;
551
+ /** A company with a domain runs its own sign-in, mail, files, jobs and calls SFU in its own containers. */
552
+ export interface ServicesConfig {
553
+ /** true: own copies of the services. false (default): the platform's shared ones. */
554
+ own: boolean;
555
+ /**
556
+ * Which slice of the server's SFU ports this company's media server takes.
557
+ * Required when `own` and `calls.enabled`. 1 to {@link SFU_SLOT_MAX}.
558
+ * Slice n holds UDP (50000 + (n-1)*500) .. (50000 + n*500 - 1) and TCP 7881 + n.
559
+ */
560
+ sfuSlot?: number | null;
561
+ }
562
+ export declare const SFU_SLOT_MAX = 20;
563
+ /** The only reader of `services.own`: an org without `services` runs the platform's shared services. */
564
+ export declare function ownsServices(org: Pick<DeclaredOrg, 'services'>): boolean;
565
+ /** The pure part of the `services` rules; that no two orgs share an `sfuSlot` is the store's check. */
566
+ export declare function validateServices(org: DeclaredOrg): string | null;
551
567
  /**
552
568
  * Per-org SSO. Decided to exist in #14 ("Also in scope") and its resolved
553
569
  * research #3 ("per-org SSO code exists unused"); nothing about its
@@ -674,8 +690,12 @@ export declare function validateBuyerIdentity(buyer: BuyerIdentity): string | nu
674
690
  * recorded invited user id, not on this declared value, so editing an owner
675
691
  * after the invite has gone out does not send a second one.
676
692
  *
677
- * `mailboxAddress` is the owner's own mailbox, when mail is on. The first
678
- * owner falls back to `mail.ownerMailboxAddress` when it is unset.
693
+ * `email` is the owner's personal address: it receives the first-sign-in link
694
+ * and is kept as the user's `personal_email` metadata. `mailboxAddress` is the
695
+ * owner's work address; the owner signs in as its local part on the org's domain.
696
+ * The first owner falls back to `mail.ownerMailboxAddress` when it is unset. An
697
+ * owner with neither is a blocked item in the plan, not refused here, because a
698
+ * stored owner from before this rule has none.
679
699
  */
680
700
  export interface OwnerIdentity {
681
701
  email: string;
@@ -818,6 +838,8 @@ export interface DeclaredOrg {
818
838
  site: SiteConfig;
819
839
  /** `enabled: false`, `issuerApps: []` by default -- a brand new org has no hosted app (#71). */
820
840
  hosting: HostingConfig;
841
+ /** Absent means `{ own: false }`. Optional so every existing literal and test fixture compiles unchanged, as `calls.versionPin` and `MailConfig.mailApps` do. */
842
+ services?: ServicesConfig;
821
843
  sso: SsoConfig;
822
844
  guests: GuestsConfig;
823
845
  solo: SoloConfig;
package/dist/org.js CHANGED
@@ -706,6 +706,32 @@ export function validateCalls(org) {
706
706
  }
707
707
  return null;
708
708
  }
709
+ export const SFU_SLOT_MAX = 20;
710
+ /** The only reader of `services.own`: an org without `services` runs the platform's shared services. */
711
+ export function ownsServices(org) {
712
+ return org.services?.own === true;
713
+ }
714
+ /** The pure part of the `services` rules; that no two orgs share an `sfuSlot` is the store's check. */
715
+ export function validateServices(org) {
716
+ const slot = org.services?.sfuSlot ?? null;
717
+ if (!ownsServices(org)) {
718
+ return slot === null ? null : 'services.sfuSlot needs services.own';
719
+ }
720
+ if (org.domain === null) {
721
+ return "services.own needs the org's own domain: the issuer is auth.<domain>";
722
+ }
723
+ if (!org.issuer.usesDomain) {
724
+ return 'services.own needs issuer.usesDomain: the issuer is auth.<domain>';
725
+ }
726
+ if (org.mail.mode !== 'off' && !org.mail.usesDomain) {
727
+ return "services.own needs mail.usesDomain: the company's mail server serves its own domain";
728
+ }
729
+ if (org.calls.enabled &&
730
+ (slot === null || !Number.isInteger(slot) || slot < 1 || slot > SFU_SLOT_MAX)) {
731
+ return `services.sfuSlot must be an integer from 1 to ${SFU_SLOT_MAX} when calls are enabled`;
732
+ }
733
+ return null;
734
+ }
709
735
  const ORG_NUMBER_PATTERN = /^\d{9}$/;
710
736
  /**
711
737
  * `null` when `buyer` is valid; otherwise the reason it is not, for a caller
@@ -746,6 +772,7 @@ const OWNER_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
746
772
  export function validateOwners(owners) {
747
773
  const seen = new Set();
748
774
  const boxes = new Set();
775
+ const names = new Set();
749
776
  for (const owner of owners) {
750
777
  if (!OWNER_EMAIL_PATTERN.test(owner.email)) {
751
778
  return `owner email '${owner.email}' is not a valid email address`;
@@ -762,6 +789,12 @@ export function validateOwners(owners) {
762
789
  if (boxes.has(box))
763
790
  return `owner mailboxAddress '${owner.mailboxAddress}' is listed more than once`;
764
791
  boxes.add(box);
792
+ // One sign-in account per local part: the account's username is the part before the @.
793
+ const name = box.slice(0, box.indexOf('@'));
794
+ if (names.has(name)) {
795
+ return `owner work addresses must have different names before the @: '${name}' is used twice`;
796
+ }
797
+ names.add(name);
765
798
  }
766
799
  const key = owner.email.toLowerCase();
767
800
  if (seen.has(key))
@@ -814,6 +847,15 @@ function validateOwnerMailboxes(org) {
814
847
  const address = owner.mailboxAddress;
815
848
  if (address === undefined || !OWNER_EMAIL_PATTERN.test(address))
816
849
  continue;
850
+ // Only an owner with a work address is checked: a stored owner without one predates the
851
+ // rule, and refusing it would stop every later update of its org.
852
+ if (org.domain && OWNER_EMAIL_PATTERN.test(owner.email)) {
853
+ const personalDomain = owner.email.slice(owner.email.indexOf('@') + 1).toLowerCase();
854
+ const orgDomain = org.domain.toLowerCase();
855
+ if (personalDomain === orgDomain || personalDomain.endsWith(`.${orgDomain}`)) {
856
+ return `owner ${owner.email} personal address must not be on ${org.domain} or a subdomain of it`;
857
+ }
858
+ }
817
859
  if (mail.usesDomain && org.domain) {
818
860
  const addressDomain = address.slice(address.indexOf('@') + 1).toLowerCase();
819
861
  if (addressDomain !== org.domain.toLowerCase()) {
@@ -28,6 +28,14 @@ export declare const BOULE_DATABASE_SCOPE_KEY = "coolify_boule_database";
28
28
  * sat built but never started, and `restart` cannot fix that.
29
29
  */
30
30
  export declare const BOULE_DEPLOYED_SCOPE_KEY = "coolify_boule_deployed";
31
+ /**
32
+ * Recorded once the platform's own Boule was restarted (or deployed) after its
33
+ * peer password and bridge secret were written. Boule creates its people-store
34
+ * role from the password only at boot, so a password written without that
35
+ * restart is useless; without this marker a failed restart was never repeated,
36
+ * because the next run finds the secrets present and mints and changes nothing.
37
+ */
38
+ export declare const BOULE_SECRETS_RESTARTED_SCOPE_KEY = "coolify_boule_secrets_restarted";
31
39
  /**
32
40
  * `boule.<domain>` when the org has a domain, `<slug>-boule.oikos.host`
33
41
  * otherwise (the tier rule of boule#128 and foundry#147; `../slug.ts`'s
@@ -56,8 +64,23 @@ export declare function bouleMailFrom(org: DeclaredOrg, mailDomain: string): str
56
64
  * values in memory only; the result names nothing secret.
57
65
  */
58
66
  export declare function bouleMailTokenDrift(liveToken: string | undefined, appToken: string | undefined): boolean;
67
+ /**
68
+ * The password of the Postgres role Archon connects to the owner company's
69
+ * people store with. Minted once, only for the platform's own org, and held in
70
+ * Boule's env; the plan compares its presence in memory and never plans with
71
+ * its value. Not in {@link desiredBouleEnv}: it is a secret.
72
+ */
73
+ export declare const BOULE_PEER_PASSWORD_ENV = "AUTHZ_PEER_PASSWORD_OPERATOR";
74
+ /**
75
+ * The secret Archon sends to Boule's invite route as a bearer token. Minted
76
+ * once, only for the platform's own org, held in Boule's env and in Archon's
77
+ * env; compared in memory like {@link BOULE_PEER_PASSWORD_ENV}.
78
+ */
79
+ export declare const BOULE_BRIDGE_SECRET_ENV = "ARCHON_BRIDGE_SECRET";
80
+ /** The role Boule creates from {@link BOULE_PEER_PASSWORD_ENV}, with grants on schema `authz` only. */
81
+ export declare const BOULE_PEER_ROLE = "authz_operator_rt";
59
82
  /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
60
- export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN"];
83
+ export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "AUTHZ_FIRST_OWNER", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN", "AUTHZ_PEER_PASSWORD_OPERATOR", "ARCHON_BRIDGE_SECRET"];
61
84
  /**
62
85
  * The env vars an org's Boule app should carry, and every input still missing
63
86
  * to build them. Callers that write must refuse on a non-empty `missing`: a
@@ -28,6 +28,14 @@ export const BOULE_DATABASE_SCOPE_KEY = 'coolify_boule_database';
28
28
  * sat built but never started, and `restart` cannot fix that.
29
29
  */
30
30
  export const BOULE_DEPLOYED_SCOPE_KEY = 'coolify_boule_deployed';
31
+ /**
32
+ * Recorded once the platform's own Boule was restarted (or deployed) after its
33
+ * peer password and bridge secret were written. Boule creates its people-store
34
+ * role from the password only at boot, so a password written without that
35
+ * restart is useless; without this marker a failed restart was never repeated,
36
+ * because the next run finds the secrets present and mints and changes nothing.
37
+ */
38
+ export const BOULE_SECRETS_RESTARTED_SCOPE_KEY = 'coolify_boule_secrets_restarted';
31
39
  /**
32
40
  * `boule.<domain>` when the org has a domain, `<slug>-boule.oikos.host`
33
41
  * otherwise (the tier rule of boule#128 and foundry#147; `../slug.ts`'s
@@ -66,6 +74,21 @@ export function bouleMailFrom(org, mailDomain) {
66
74
  export function bouleMailTokenDrift(liveToken, appToken) {
67
75
  return !liveToken || (Boolean(appToken) && appToken !== liveToken);
68
76
  }
77
+ /**
78
+ * The password of the Postgres role Archon connects to the owner company's
79
+ * people store with. Minted once, only for the platform's own org, and held in
80
+ * Boule's env; the plan compares its presence in memory and never plans with
81
+ * its value. Not in {@link desiredBouleEnv}: it is a secret.
82
+ */
83
+ export const BOULE_PEER_PASSWORD_ENV = 'AUTHZ_PEER_PASSWORD_OPERATOR';
84
+ /**
85
+ * The secret Archon sends to Boule's invite route as a bearer token. Minted
86
+ * once, only for the platform's own org, held in Boule's env and in Archon's
87
+ * env; compared in memory like {@link BOULE_PEER_PASSWORD_ENV}.
88
+ */
89
+ export const BOULE_BRIDGE_SECRET_ENV = 'ARCHON_BRIDGE_SECRET';
90
+ /** The role Boule creates from {@link BOULE_PEER_PASSWORD_ENV}, with grants on schema `authz` only. */
91
+ export const BOULE_PEER_ROLE = 'authz_operator_rt';
69
92
  /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
70
93
  export const BOULE_MANAGED_ENV_KEYS = [
71
94
  'APP_URL',
@@ -74,9 +97,12 @@ export const BOULE_MANAGED_ENV_KEYS = [
74
97
  'AUTH_CLIENT_ID',
75
98
  'BOULE_ORG_NAME',
76
99
  'AUTHZ_BOOTSTRAP_OPERATOR',
100
+ 'AUTHZ_FIRST_OWNER',
77
101
  'ARCHON_URL',
78
102
  'MAIL_FROM',
79
103
  BOULE_MAIL_TOKEN_ENV,
104
+ BOULE_PEER_PASSWORD_ENV,
105
+ BOULE_BRIDGE_SECRET_ENV,
80
106
  ];
81
107
  /**
82
108
  * The env vars an org's Boule app should carry, and every input still missing
@@ -129,8 +155,10 @@ export function desiredBouleEnv(org, recorded, issuer, archonUrl, mailWanted = f
129
155
  const ownerUserId = owner
130
156
  ? recorded[`zitadel:owner:${org.id}:${owner.email.toLowerCase()}`]
131
157
  : undefined;
132
- if (ownerUserId)
158
+ if (ownerUserId) {
133
159
  env.AUTHZ_BOOTSTRAP_OPERATOR = ownerUserId;
160
+ env.AUTHZ_FIRST_OWNER = ownerUserId;
161
+ }
134
162
  const mailDomain = recorded[MAIL_DOMAIN_RECORDED_KEY];
135
163
  if (mailWanted && mailDomain)
136
164
  env.MAIL_FROM = bouleMailFrom(org, mailDomain);
@@ -47,6 +47,15 @@
47
47
  export declare const SYNAPSE_IMAGE = "ghcr.io/element-hq/synapse";
48
48
  export declare const SYNAPSE_TAG: string;
49
49
  export declare const MAS_IMAGE: string;
50
+ /** MAS listens here in both the calls and the chat config (`http.listeners[0].binds`). */
51
+ export declare const MAS_PORT = 8080;
52
+ /**
53
+ * The inline Dockerfile of a MAS app: the published image plus its entry command. That image is distroless: no
54
+ * shell and no HTTP client, so Coolify's own check (always `sh -c`) cannot run in it. A static busybox is copied in
55
+ * and an exec-form HEALTHCHECK asks MAS's `/health` (a `SELECT 1` on its database). Coolify keeps a Dockerfile's
56
+ * HEALTHCHECK when its own check is off (`health_check_enabled: false`).
57
+ */
58
+ export declare function masDockerfile(): string;
50
59
  /** #251: the version baked into {@link MAS_IMAGE}'s `FROM` line, named on its own so a version-change line can read it without re-parsing the image string. */
51
60
  export declare const MAS_TAG: string;
52
61
  /**
@@ -49,6 +49,23 @@ import { VERSIONS } from '../bootstrap/versions.js';
49
49
  export const SYNAPSE_IMAGE = 'ghcr.io/element-hq/synapse';
50
50
  export const SYNAPSE_TAG = VERSIONS.synapse;
51
51
  export const MAS_IMAGE = `ghcr.io/element-hq/matrix-authentication-service:${VERSIONS.mas}`;
52
+ /** MAS listens here in both the calls and the chat config (`http.listeners[0].binds`). */
53
+ export const MAS_PORT = 8080;
54
+ /**
55
+ * The inline Dockerfile of a MAS app: the published image plus its entry command. That image is distroless: no
56
+ * shell and no HTTP client, so Coolify's own check (always `sh -c`) cannot run in it. A static busybox is copied in
57
+ * and an exec-form HEALTHCHECK asks MAS's `/health` (a `SELECT 1` on its database). Coolify keeps a Dockerfile's
58
+ * HEALTHCHECK when its own check is off (`health_check_enabled: false`).
59
+ */
60
+ export function masDockerfile() {
61
+ return [
62
+ `FROM busybox:${VERSIONS.busybox} AS busybox`,
63
+ `FROM ${MAS_IMAGE}`,
64
+ 'COPY --from=busybox /bin/busybox /busybox',
65
+ 'CMD ["server", "--config", "/config/config.yaml"]',
66
+ `HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 CMD ["/busybox", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:${MAS_PORT}/health"]`,
67
+ ].join('\n');
68
+ }
52
69
  /** #251: the version baked into {@link MAS_IMAGE}'s `FROM` line, named on its own so a version-change line can read it without re-parsing the image string. */
53
70
  export const MAS_TAG = VERSIONS.mas;
54
71
  /**
@@ -7,6 +7,12 @@ import type { CoolifyPort } from './executor.js';
7
7
  * `versions.json` (#247).
8
8
  */
9
9
  export declare const CLOUDFLARED_IMAGE_REF: string;
10
+ /**
11
+ * Where the connector serves its metrics, which include `/ready` (200 with at least one edge connection, else 503).
12
+ * The image is distroless, so its check is the binary's own `tunnel ready` in exec form: a Dockerfile HEALTHCHECK,
13
+ * kept by Coolify while its own check is off. The port is not published; only the container network reaches it.
14
+ */
15
+ export declare const CLOUDFLARED_METRICS_PORT = 20241;
10
16
  /**
11
17
  * The inline Dockerfile a docker-image `cloudflared` app needs, copied from
12
18
  * `scripts/shared-ingress.mjs`'s `cloudflaredDockerfile()`: the published
@@ -24,7 +30,7 @@ export declare function cloudflaredDockerfile(): string;
24
30
  * archon) calls this once with its own app name and scope key.
25
31
  */
26
32
  export interface EnsureCloudflaredConnectorInput {
27
- coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'createDockerfileApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy'>;
33
+ coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy'>;
28
34
  tunnels: Pick<TunnelPort, 'getTunnelToken'>;
29
35
  /** The Cloudflare tunnel this connector dials into (already created and configured by the caller). */
30
36
  tunnelId: string;
@@ -7,6 +7,12 @@ import { isNotFoundError } from './executor.js';
7
7
  * `versions.json` (#247).
8
8
  */
9
9
  export const CLOUDFLARED_IMAGE_REF = `cloudflare/cloudflared:${VERSIONS.cloudflared}`;
10
+ /**
11
+ * Where the connector serves its metrics, which include `/ready` (200 with at least one edge connection, else 503).
12
+ * The image is distroless, so its check is the binary's own `tunnel ready` in exec form: a Dockerfile HEALTHCHECK,
13
+ * kept by Coolify while its own check is off. The port is not published; only the container network reaches it.
14
+ */
15
+ export const CLOUDFLARED_METRICS_PORT = 20241;
10
16
  /**
11
17
  * The inline Dockerfile a docker-image `cloudflared` app needs, copied from
12
18
  * `scripts/shared-ingress.mjs`'s `cloudflaredDockerfile()`: the published
@@ -17,7 +23,12 @@ export const CLOUDFLARED_IMAGE_REF = `cloudflare/cloudflared:${VERSIONS.cloudfla
17
23
  * variable `cloudflared` reads at boot.
18
24
  */
19
25
  export function cloudflaredDockerfile() {
20
- return `FROM ${CLOUDFLARED_IMAGE_REF}\nCMD ["tunnel", "--no-autoupdate", "run"]\n`;
26
+ return [
27
+ `FROM ${CLOUDFLARED_IMAGE_REF}`,
28
+ `CMD ["tunnel", "--no-autoupdate", "--metrics", "0.0.0.0:${CLOUDFLARED_METRICS_PORT}", "run"]`,
29
+ `HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 CMD ["cloudflared", "tunnel", "--metrics", "127.0.0.1:${CLOUDFLARED_METRICS_PORT}", "ready"]`,
30
+ '',
31
+ ].join('\n');
21
32
  }
22
33
  /**
23
34
  * Finds or creates the one Coolify app that runs `cloudflared` for
@@ -79,6 +90,8 @@ export async function ensureCloudflaredConnector(input) {
79
90
  });
80
91
  appId = created.uuid;
81
92
  justCreated = true;
93
+ // Before the first deploy: Coolify reads the Dockerfile's HEALTHCHECK only while its own check is off.
94
+ await coolify.updateApplication(appId, { health_check_enabled: false });
82
95
  }
83
96
  if (appId !== input.recordedConnectorAppId)
84
97
  await input.recordAppId(appId);
@@ -43,6 +43,8 @@ export interface DraftStep {
43
43
  export interface PlanOrgOptions {
44
44
  /** #267: the executor was built with a `loginHostBindings` port. */
45
45
  loginHostBindings?: boolean;
46
+ /** A live read said the org's login host serves OIDC discovery as `https://<host>`. Absent: not read, so Agora keeps the platform issuer. */
47
+ loginHostServesDiscovery?: boolean;
46
48
  /** The executor's ZITADEL client has `updateOidcAppIdTokenUserinfoAssertion`; without it `planIdTokenUserinfo` plans nothing. */
47
49
  zitadelIdTokenUserinfo?: boolean;
48
50
  /** F5: the executor was built with a `broker` port. */
@@ -55,8 +57,37 @@ export interface PlanOrgOptions {
55
57
  archonUrl?: string | null;
56
58
  /** #318: the executor was built with a `WorkersPort`, read by `planWorkers`. */
57
59
  edgeWorkers?: boolean;
60
+ /** The executor was built with an `ownerInviteMail` port; without it each owner's invite is blocked. */
61
+ ownerInviteMail?: boolean;
58
62
  /** The platform's own service hostnames: `edgeReservedLabels` reserves them under the org's domain, and a wildcard Worker route (`planWorkers`) keeps them out. */
59
63
  platformHostnames?: readonly string[];
64
+ /** The executor was built with an `ownServices` port. Without it an own org's own-service steps are blocked. */
65
+ ownServices?: boolean;
66
+ /** An own org's Lettermint key (`orgTarget(org).refs.brokerMailKey`) is held. */
67
+ ownMailKeyHeld?: boolean;
68
+ /** The executor can mint an own org's Lettermint key (`FoundryStepExecutor.ownMailKeyMintable`). */
69
+ ownMailKeyMintable?: boolean;
70
+ /** The own jobs admin token's held-key ref holds a value. */
71
+ ownJobsTokenHeld?: boolean;
72
+ /** The own media server's key pair ref holds a value. */
73
+ ownSfuKeysHeld?: boolean;
74
+ /** The relay is declared and has applied a route list; an own org's mail server waits for it. */
75
+ mailRelayApplied?: boolean;
76
+ /** The host confirmed the ports of an own org's media server slot are open on the server. */
77
+ sfuPortsOpen?: boolean;
78
+ /** What the executor's own files dependencies supply: valid keys worker settings, and a bucket port. Absent counts as neither. */
79
+ ownFilesDeps?: {
80
+ keys: boolean;
81
+ bucket: boolean;
82
+ };
83
+ /**
84
+ * Which ports the own files storage key step needs the host supplied. Absent: the executor was not
85
+ * asked, and the step is neither planned nor reported.
86
+ */
87
+ ownFilesR2Ports?: {
88
+ r2Keys: boolean;
89
+ storeR2Credentials: boolean;
90
+ };
60
91
  }
61
92
  export interface PlanDetail {
62
93
  orgId: OrgId;
@@ -134,7 +165,7 @@ export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[]
134
165
  * computed `auth.<domain>` against, so the platform's own org never plans
135
166
  * its login host equal to the shared ZITADEL instance's own.
136
167
  */
137
- export declare function isPlatformOwnOrg(org: DeclaredOrg, platformApex: string | null): boolean;
168
+ export declare function isPlatformOwnOrg(org: Pick<DeclaredOrg, 'id'>, platformOwnerOrgId: string | null): boolean;
138
169
  /**
139
170
  * What {@link planOrgPostgres} reports about the org's one shared Postgres
140
171
  * server and its fixed schema topology (#444a, "manifest-and-plan-only"):