@wtfalch/foundry 0.33.1 → 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 (65) hide show
  1. package/dist/bootstrap/versions.d.ts +4 -0
  2. package/dist/calls-sfu.d.ts +10 -0
  3. package/dist/calls-sfu.js +41 -0
  4. package/dist/index.d.ts +14 -6
  5. package/dist/index.js +7 -3
  6. package/dist/migrations/0051_org_services.sql +13 -0
  7. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  8. package/dist/org.d.ts +18 -0
  9. package/dist/org.js +26 -0
  10. package/dist/plan/calls-matrix.d.ts +9 -0
  11. package/dist/plan/calls-matrix.js +17 -0
  12. package/dist/plan/cloudflared-connector.d.ts +7 -1
  13. package/dist/plan/cloudflared-connector.js +14 -1
  14. package/dist/plan/diff.d.ts +29 -0
  15. package/dist/plan/diff.js +259 -18
  16. package/dist/plan/executor.d.ts +112 -0
  17. package/dist/plan/executor.js +594 -33
  18. package/dist/plan/health-check-settings.d.ts +28 -0
  19. package/dist/plan/health-check-settings.js +36 -0
  20. package/dist/plan/health-gaps.d.ts +33 -0
  21. package/dist/plan/health-gaps.js +89 -0
  22. package/dist/plan/held-key-refs.d.ts +10 -0
  23. package/dist/plan/held-key-refs.js +10 -0
  24. package/dist/plan/index.d.ts +14 -6
  25. package/dist/plan/index.js +7 -3
  26. package/dist/plan/live.d.ts +5 -2
  27. package/dist/plan/live.js +15 -2
  28. package/dist/plan/locked-hostnames.d.ts +5 -2
  29. package/dist/plan/locked-hostnames.js +9 -1
  30. package/dist/plan/login-host.d.ts +62 -2
  31. package/dist/plan/login-host.js +104 -1
  32. package/dist/plan/mail-relay.d.ts +120 -0
  33. package/dist/plan/mail-relay.js +323 -0
  34. package/dist/plan/org-names.d.ts +24 -0
  35. package/dist/plan/org-names.js +52 -0
  36. package/dist/plan/own-diff.d.ts +169 -0
  37. package/dist/plan/own-diff.js +432 -0
  38. package/dist/plan/own-dns.d.ts +6 -0
  39. package/dist/plan/own-dns.js +15 -0
  40. package/dist/plan/own-limits.d.ts +22 -0
  41. package/dist/plan/own-limits.js +101 -0
  42. package/dist/plan/own-services.d.ts +193 -0
  43. package/dist/plan/own-services.js +167 -0
  44. package/dist/plan/own-sfu.d.ts +69 -0
  45. package/dist/plan/own-sfu.js +255 -0
  46. package/dist/plan/plan-apply.d.ts +78 -7
  47. package/dist/plan/plan-apply.js +205 -12
  48. package/dist/plan/platform-diff.d.ts +28 -0
  49. package/dist/plan/platform-diff.js +45 -3
  50. package/dist/plan/platform-org.d.ts +34 -0
  51. package/dist/plan/platform-org.js +94 -2
  52. package/dist/plan/platform-providers.d.ts +356 -8
  53. package/dist/plan/platform-providers.js +661 -353
  54. package/dist/plan/sign-in-org.d.ts +1 -1
  55. package/dist/plan/sign-in-org.js +20 -2
  56. package/dist/plan/zitadel-login-policy.js +1 -1
  57. package/dist/plan/zitadel-system-api.d.ts +22 -1
  58. package/dist/plan/zitadel-system-api.js +49 -8
  59. package/dist/platform.d.ts +8 -0
  60. package/dist/slug.js +1 -0
  61. package/dist/store/postgres.js +51 -2
  62. package/dist/store/schema.d.ts +51 -0
  63. package/dist/store/schema.js +5 -0
  64. package/package.json +1 -1
  65. package/versions.json +3 -1
@@ -1,6 +1,8 @@
1
+ import { ownsServices } from '../org.js';
1
2
  import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
2
- import { JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AUTH_KEY, PLATFORM_EDGE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
3
- import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './platform-providers.js';
3
+ import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
4
+ import { OWN_SERVICES_APPLIED_SCOPE_KEY, orgTarget } from './own-services.js';
5
+ import { JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
4
6
  /**
5
7
  * #443: the no-domain company mail apex -- `<slug>.oikos.host`, the same
6
8
  * fixed suffix `agora.ts`'s `agoraHostnameFor`, `boule.ts`'s
@@ -126,6 +128,7 @@ edgePortPresent = false) {
126
128
  // enabled or applied.
127
129
  platformApex: platform.apex,
128
130
  platformOwnerOrgId: platform.ownerOrgId || null,
131
+ platformOwnerUserId: platformRecordedIds[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY] || null,
129
132
  platformLettermintProjectId: platform.mail.lettermintProjectId ?? null,
130
133
  filesReady: filesApplied && missingKeys.files === null,
131
134
  filesUrl: filesApplied
@@ -147,6 +150,95 @@ edgePortPresent = false) {
147
150
  ...(noKeyReader ? { noKeyReader } : {}),
148
151
  };
149
152
  }
153
+ /** The git refs the platform declares for the services an own org deploys. */
154
+ export function serviceGitRefsOf(platform) {
155
+ return {
156
+ auth: platform.auth.gitRef,
157
+ mail: platform.mail.gitRef,
158
+ files: platform.files.gitRef,
159
+ jobs: platform.jobs.gitRef,
160
+ };
161
+ }
162
+ /** The runner app both the platform's and an own org's files step record. */
163
+ export const OWN_FILES_RUNNER_SCOPE_KEY = 'coolify_platform_files_runner_app';
164
+ const OWN_MAIL_APP_SCOPE_KEY = 'coolify_platform_mail_app';
165
+ /** Resource id of an own-service step, in `AppliedState.serviceIds`' `system:kind:key` form. */
166
+ function ownStepRecorded(ids, org, kind) {
167
+ return Boolean(ids[`coolify:${kind}:${org.id}`]);
168
+ }
169
+ /**
170
+ * The own org's sign-in is up: applied, so its ZITADEL answers. The broker and the login client
171
+ * come after the org's first broker entry, as the platform's do after its first registration, so
172
+ * waiting for them here would wait for the very steps this gate lets run.
173
+ */
174
+ function ownAuthReady(ids) {
175
+ return Boolean(ids[OWN_SERVICES_APPLIED_SCOPE_KEY]);
176
+ }
177
+ /**
178
+ * An own org (`services.own`) plans against its own services, so its readiness
179
+ * reads only its own recorded ids and its own held keys; nothing the shared
180
+ * services recorded reaches it. AI, edge and Access stay shared and come from
181
+ * `base`. Any other org gets `base` back unchanged.
182
+ */
183
+ export function orgPlatformReadinessFor(org, base, orgRecordedIds, heldRefs) {
184
+ if (!ownsServices(org))
185
+ return base;
186
+ const refs = orgTarget(org).refs;
187
+ const authReady = ownAuthReady(orgRecordedIds);
188
+ const filesApplied = ownStepRecorded(orgRecordedIds, org, 'own-files') &&
189
+ Boolean(orgRecordedIds[OWN_FILES_RUNNER_SCOPE_KEY]);
190
+ const jobsApplied = ownStepRecorded(orgRecordedIds, org, 'own-jobs');
191
+ const mailApplied = ownStepRecorded(orgRecordedIds, org, 'own-mail') &&
192
+ Boolean(orgRecordedIds[OWN_MAIL_APP_SCOPE_KEY]);
193
+ const filesUrl = orgRecordedIds[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY];
194
+ const jobsUrl = orgRecordedIds[JOBS_INTERNAL_URL_SCOPE_KEY];
195
+ const missing = (applied, ref) => applied && !heldRefs.has(ref) ? ref : null;
196
+ const missingKeys = {
197
+ files: missing(filesApplied, refs.filesManagement),
198
+ ai: base.missingKeys.ai,
199
+ jobs: missing(jobsApplied, refs.jobsAdminToken),
200
+ };
201
+ const { jobsUrlMissing: _shared, ...rest } = base;
202
+ return {
203
+ ...rest,
204
+ authReady,
205
+ authIssuer: authReady && org.domain ? `https://auth.${org.domain}` : null,
206
+ filesReady: filesApplied && filesUrl !== undefined && missingKeys.files === null,
207
+ filesUrl: filesApplied ? (filesUrl ?? null) : null,
208
+ jobsReady: jobsApplied && jobsUrl !== undefined && missingKeys.jobs === null,
209
+ ...(jobsApplied && jobsUrl === undefined ? { jobsUrlMissing: true } : {}),
210
+ callsReady: ownStepRecorded(orgRecordedIds, org, 'own-sfu'),
211
+ mailHost: mailApplied && org.domain && base.mailHost
212
+ ? { apex: org.domain, mailHost: base.mailHost.mailHost }
213
+ : null,
214
+ missingKeys,
215
+ };
216
+ }
217
+ /** The own org's held-key refs `heldKeys` resolves right now. A resolver that throws counts as not held, and its error goes nowhere. */
218
+ export async function heldOwnKeyRefs(heldKeys, org) {
219
+ const held = new Set();
220
+ for (const ref of Object.values(orgTarget(org).refs)) {
221
+ try {
222
+ if (await heldKeys.resolve(ref))
223
+ held.add(ref);
224
+ }
225
+ catch {
226
+ // not held
227
+ }
228
+ }
229
+ return held;
230
+ }
231
+ /** {@link agoraHostValuesFromPlatform}'s counterpart for an own org: every value is the org's own, null until recorded. */
232
+ export function agoraHostValuesForOwn(org, orgRecordedIds) {
233
+ const authReady = ownAuthReady(orgRecordedIds);
234
+ return {
235
+ issuer: authReady && org.domain ? `https://auth.${org.domain}` : null,
236
+ mailProjectId: orgRecordedIds[PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY] ?? null,
237
+ messagingProjectId: null,
238
+ mailUpstream: orgRecordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY] ?? null,
239
+ filesUpstream: orgRecordedIds[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] ?? null,
240
+ };
241
+ }
150
242
  /**
151
243
  * #177: {@link AgoraHostValues} derived from the same platform state, rather
152
244
  * than requiring a bespoke host-supplied `AgoraHostPort` for values the
@@ -8,12 +8,16 @@ import type { AccessPort } from './edge-access.js';
8
8
  import type { DnsPort } from './edge-dns.js';
9
9
  import type { TunnelPort } from './edge-tunnels.js';
10
10
  import type { CloudflarePort, CoolifyPort, FoundryStepExecutorPorts, ZitadelPort } from './executor.js';
11
+ export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
11
12
  import type { LiveResource, PlatformCloudflareReader, PlatformCoolifyReader } from './live.js';
13
+ import { type FilesKeysMinted, type ProjectSource, type ServiceNetwork, type ServiceTarget, type StepScope } from './own-services.js';
12
14
  import type { PlatformDriftedSteps, PlatformStepExecutionContext, PlatformStepExecutor } from './plan-apply.js';
13
15
  import { type BrokerDeployRecord, PLATFORM_ZITADEL_ORG_SCOPE_KEY } from './platform-diff.js';
14
16
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
15
17
  import { type CallsSfuPlatformDeps } from './platform-sfu.js';
16
18
  import { type PlatformUptimeDeps } from './uptime-worker.js';
19
+ /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
20
+ export declare function apexZoneKey(apex: string): string;
17
21
  /**
18
22
  * #168: everything {@link provisionPlatformAuth} needs beyond
19
23
  * {@link PlatformStepExecutionContext} -- a bundle, not more constructor
@@ -28,6 +32,15 @@ import { type PlatformUptimeDeps } from './uptime-worker.js';
28
32
  * being the platform key held under {@link PLATFORM_EDGE_KEY_REF}.
29
33
  */
30
34
  export type PlatformDnsFactory = (edgeUrl: string) => DnsPort | Promise<DnsPort>;
35
+ /**
36
+ * The DNS port for a platform step: edge's recorded address, never a public
37
+ * hostname, because auth, mail and archon write their records before any of
38
+ * those exist. Edge's step runs first and records the address
39
+ * (`planPlatform`), so a missing one means that step has not finished.
40
+ */
41
+ export declare function platformDns(deps: {
42
+ dns: PlatformDnsFactory;
43
+ }, recorded: Readonly<Record<string, string>>, step: string): Promise<DnsPort>;
31
44
  /**
32
45
  * F4.tunnels: builds the {@link TunnelPort} a platform step creates, configures
33
46
  * and reads the connector token of its tunnel through, given the same address
@@ -38,6 +51,10 @@ export type PlatformDnsFactory = (edgeUrl: string) => DnsPort | Promise<DnsPort>
38
51
  export type PlatformTunnelsFactory = (edgeUrl: string) => PlatformTunnelPort | Promise<PlatformTunnelPort>;
39
52
  /** The tunnel calls a platform step makes; it never deletes one. */
40
53
  export type PlatformTunnelPort = Pick<TunnelPort, 'findTunnelByName' | 'createTunnel' | 'putTunnelConfig' | 'getTunnelToken'>;
54
+ /** The tunnel port for a platform step; same address and same reason as {@link platformDns}. */
55
+ export declare function platformTunnels(deps: {
56
+ tunnels: PlatformTunnelsFactory;
57
+ }, recorded: Readonly<Record<string, string>>, step: string): Promise<PlatformTunnelPort>;
41
58
  /**
42
59
  * F4.access: builds the {@link AccessPort} a platform step finds, creates and
43
60
  * deletes its Access app through, given the same address as
@@ -105,7 +122,7 @@ export interface PlatformAuthDeps {
105
122
  * {@link zitadel}, so the mint always hits the instance this step just
106
123
  * built. See `createZitadelAdminMinter` for the mint half.
107
124
  */
108
- zitadelForInstance?: (issuerUrl: string) => PlatformAuthDeps['zitadel'];
125
+ zitadelForInstance?: (issuerUrl: string, bearerRef?: string) => PlatformAuthDeps['zitadel'];
109
126
  deliverSecret: FoundryStepExecutorPorts['deliverSecret'];
110
127
  /**
111
128
  * #210: reads back the broker's Lettermint mail key, held under
@@ -133,9 +150,19 @@ export interface PlatformAuthRuntime {
133
150
  readyIntervalMs?: number;
134
151
  /** Tries at handing the admin key to the host (default 3). */
135
152
  deliverAttempts?: number;
153
+ /** Timeout of each readiness poll request (default 10 seconds). */
154
+ readyRequestTimeoutMs?: number;
155
+ /** Told of every secret the sign-in step reads or mints, so the host can scrub it from logs and audit rows. */
156
+ onSecret?: (value: string) => void;
136
157
  }
137
- /** The ref the scheduler's admin token is delivered under: the host's real `JobsSchedulerPort` adapter reads it back. */
138
- export declare const JOBS_ADMIN_TOKEN_REF = "jobs:scheduler:adminToken";
158
+ /**
159
+ * An own org's scheduler only: the app's uuid, recorded and persisted before
160
+ * the admin token leaves and cleared once the token is delivered and written.
161
+ * Left set, the planner reports the scheduler blocked and never mints again.
162
+ */
163
+ export declare const JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY = "jobs_admin_token_undelivered";
164
+ /** An own org's scheduler only: the git ref its app last deployed, so a changed declared ref plans the step again. */
165
+ export declare const JOBS_APPLIED_GIT_REF_SCOPE_KEY = "jobs_applied_git_ref";
139
166
  /**
140
167
  * The narrow slice of {@link CoolifyPort} {@link FoundryPlatformStepExecutor}
141
168
  * needs for `coolify:jobs` (#173) -- the same project/environment/server/app/
@@ -143,7 +170,7 @@ export declare const JOBS_ADMIN_TOKEN_REF = "jobs:scheduler:adminToken";
143
170
  * org's own app, reused here for the platform's one scheduler instead of once
144
171
  * per org.
145
172
  */
146
- type JobsCoolifyPort = Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'requireGithubApp' | 'findApplicationByName' | 'createGithubAppApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'getApplication' | 'updateApplication' | 'deployAndWait'>;
173
+ export type JobsCoolifyPort = Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'requireGithubApp' | 'findApplicationByName' | 'createGithubAppApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'getApplication' | 'updateApplication' | 'deployAndWait'>;
147
174
  /**
148
175
  * Options for {@link FoundryPlatformStepExecutor}'s constructor, replacing
149
176
  * four positional arguments that shifted on every merge that added a
@@ -330,6 +357,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
330
357
  edge?: string;
331
358
  archon?: string;
332
359
  mail?: string;
360
+ relay?: string;
333
361
  accessProvider?: string;
334
362
  backupExecutorMissing?: boolean;
335
363
  hc?: string;
@@ -386,7 +414,51 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
386
414
  private executeSfu;
387
415
  private coolifyJobs;
388
416
  }
417
+ export interface JobsHostDeps {
418
+ coolify: JobsCoolifyPort;
419
+ /** `FoundryStepExecutorPorts.deliverSecret`'s contract: never returned, recorded, logged or put in an error. */
420
+ deliverSecret: (ref: string, secret: string) => Promise<void>;
421
+ /** Reads a delivered secret back from the host. Without it, an org's undelivered marker cannot be resumed. */
422
+ readSecret?: (ref: string) => Promise<string | null>;
423
+ runtime: DeployHealthRuntime;
424
+ }
425
+ export interface JobsHostInput {
426
+ /** The scheduler's public domain; null leaves the app on Coolify's own, for a scheduler only containers reach. */
427
+ hostname: string | null;
428
+ gitRef: string;
429
+ project: ProjectSource;
430
+ }
431
+ /**
432
+ * Provisions one jobs scheduler (database, app, admin token) for `target`:
433
+ * the platform's, or an own org's. Every name and the held-key ref come from
434
+ * the target.
435
+ *
436
+ * An org target also guards the mint (`CLAUDE.md`, "Platform steps" rules 1
437
+ * and 3): the token's id is recorded as undelivered and persisted before the
438
+ * token leaves, the delivered guard is recorded after the env write, and the
439
+ * applied git ref is recorded so a changed ref plans the step again. The
440
+ * platform's step keeps its provision-only behaviour.
441
+ */
442
+ export declare function provisionJobsHost(scope: StepScope, deps: JobsHostDeps, target: ServiceTarget, input: JobsHostInput): Promise<{
443
+ resourceId?: string;
444
+ }>;
445
+ /**
446
+ * Fixed `custom_internal_name`s (`diff.ts`'s `coolifyCalls` sets the same
447
+ * kind of name on Synapse/MAS) so the Cloudflare tunnel below can address
448
+ * each app directly, by container name, on the shared Coolify Docker network
449
+ * -- bypassing Coolify's own Traefik, which dispatches purely by `Host`
450
+ * header and so cannot give two different apps the same public hostname.
451
+ */
389
452
  export declare const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = "platform-auth-login";
453
+ /**
454
+ * The inline Dockerfile of the ZITADEL app. The published image is distroless (no shell, no `curl`) and has no
455
+ * HEALTHCHECK, and Coolify runs its own check as `sh -c`. So the app is a Dockerfile app: the image's own `ready`
456
+ * command is its exec-form HEALTHCHECK, which Coolify keeps while its own check is off. `ready` asks the port and
457
+ * TLS mode from the same `ZITADEL_*` settings the server reads. The start period covers the first run, which sets
458
+ * up the database and the first instance before it listens. The start command is the one
459
+ * {@link AUTH_ZITADEL_RUN_OPTIONS} names.
460
+ */
461
+ export declare function zitadelDockerfile(): string;
390
462
  export declare const AUTH_LOGIN_PORT = "3000";
391
463
  /**
392
464
  * Every ZITADEL OIDC/management/console/asset path -- `deploy/proxy/Caddyfile`'s
@@ -396,6 +468,8 @@ export declare const AUTH_LOGIN_PORT = "3000";
396
468
  * everything else, including the root).
397
469
  */
398
470
  export declare const AUTH_ZITADEL_PATH_PATTERN = "^/(oidc|oauth|saml|management|admin|system|auth|assets|debug/healthz|ui/console|\\.well-known|v2|v2beta|zitadel\\.[A-Za-z0-9.]+)(/|$)";
471
+ export declare const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = "coolify_platform_auth_zitadel_app";
472
+ export declare const PLATFORM_AUTH_LOGIN_SCOPE_KEY = "coolify_platform_auth_login_app";
399
473
  /**
400
474
  * #283: the ref the new instance's IAM_OWNER machine token (a PAT) is
401
475
  * delivered under, once. A host reads it back to build `zitadelForInstance`
@@ -412,6 +486,98 @@ export declare const PLATFORM_AUTH_INSTANCE_BEARER_REF = "zitadel:platform:insta
412
486
  * (`wtfalch/auth/docs/deploy.md`).
413
487
  */
414
488
  export declare const PLATFORM_AUTH_BROKER_MAIL_KEY_REF = "lettermint:platform:apiToken";
489
+ /** What {@link provisionAuthStack} reads besides its {@link ServiceTarget}: the platform's values, or one company's. */
490
+ export interface AuthStackInput {
491
+ hostname: string;
492
+ apex: string;
493
+ gitRef: string;
494
+ project: ProjectSource;
495
+ network: ServiceNetwork;
496
+ /** The ZITADEL SMTP sender address. */
497
+ smtpSender: string;
498
+ /** Where the login app sends a user with no sign-in request in flight, or null to leave it unset. */
499
+ defaultRedirectUri: string | null;
500
+ /** The company whose owner is invited into its own sign-in org; null for a company's own stack. */
501
+ ownerCompany: DeclaredOrg | null;
502
+ }
503
+ /**
504
+ * #168: makes `coolify:app` keyed {@link PLATFORM_AUTH_KEY} true -- a Coolify
505
+ * Postgres, the ZITADEL app itself, the zitadel-login app and (#204) the
506
+ * sign-in broker, reached through one Cloudflare tunnel and DNS record at
507
+ * `platform.auth.hostname`, then (only the first time) foundry's own admin
508
+ * machine user, handed to {@link PlatformAuthDeps.deliverSecret}. Env values
509
+ * and instance settings are `wtfalch/auth`'s own `docker-compose.yml`
510
+ * (checked 2026-09-29), with `ZITADEL_DOMAIN`/`companyincluded.com` replaced by this
511
+ * platform's own `apex`/`auth.hostname` wherever the compose named one --
512
+ * every other setting it leaves open (login v2 required, external port 443,
513
+ * TLS terminated at Cloudflare, `PASSWORDCHANGEREQUIRED` false) is kept
514
+ * exactly as that compose has it (#168's "Decide"). The estate's old, shared
515
+ * sign-in host carries nothing once this runs (#204, `WTFALCH-DEV-EXIT.md`
516
+ * row A4).
517
+ *
518
+ * Every part is safe to run again after a throw partway: each id is recorded
519
+ * (and persisted) as soon as its resource exists, each app is named and
520
+ * deployed on every run that needs it, and the admin key is delivered before
521
+ * the guard that skips the mint is recorded.
522
+ *
523
+ * Three apps, one public hostname, no separate `proxy` app: `wtfalch/auth`
524
+ * splits ZITADEL's API, its login UI and its broker with a built Caddy proxy
525
+ * container inside one docker-compose stack; foundry instead gives each app a
526
+ * fixed `custom_internal_name` and lets the Cloudflare tunnel itself
527
+ * path-route between them (`AUTH_ZITADEL_PATH_PATTERN`,
528
+ * `AUTH_BROKER_PATH_PATTERN`), skipping Coolify's own Traefik (which can only
529
+ * dispatch by `Host`, not by path) rather than provisioning a fourth Coolify
530
+ * resource to do the same job. No app is created with a Coolify `domains`
531
+ * binding for the same reason -- the tunnel reaches each one directly by its
532
+ * `custom_internal_name`, never through Traefik.
533
+ *
534
+ * Known gaps, left for review (#168's own "the PR stays open for William's
535
+ * review"):
536
+ * - ~~Neither the login app nor the broker ever receives
537
+ * `ZITADEL_SERVICE_USER_TOKEN`.~~ Closed by #216 (building on #209/#215):
538
+ * this function's own login-client block below mints a dedicated
539
+ * `IAM_LOGIN_CLIENT` machine user (never the admin one, which is
540
+ * `IAM_OWNER`) and delivers its token to both apps, once the broker's
541
+ * other required inputs are already met.
542
+ * - ~~The broker never receives the earlier mail provider's token either, so it will not
543
+ * start on `AUTH_APPS` alone.~~ Closed by #210, once `wtfalch/auth#138`
544
+ * moved the broker's account mail onto `@wtfalch/mailer`'s Lettermint
545
+ * transport: this function now reads the key back from
546
+ * {@link PlatformAuthDeps.heldKeys} under
547
+ * {@link PLATFORM_AUTH_BROKER_MAIL_KEY_REF} and pushes it as
548
+ * `LETTERMINT_API_TOKEN`, once a human (or Archon's own onboarding) has
549
+ * put one there. Still a gap until then, the same "blocked, not failing"
550
+ * way an unset `AUTH_APPS` already is.
551
+ * - `AUTH_APPS` starts unset: the broker has nothing to serve until a
552
+ * platform app registers with it ({@link provisionPlatformArchon} is the
553
+ * first), so it will not start (`parseApps` throws on an empty list) until
554
+ * then either. Safe to run again: the next apply that registers an app
555
+ * redeploys the broker with a non-empty list.
556
+ * - ~~Whoever supplies `deps.zitadel` (or `zitadelForInstance`) must hold a
557
+ * bearer token for the new instance, which foundry cannot read from it.~~
558
+ * Closed by #283: see "The instance bearer" below.
559
+ *
560
+ * The instance bearer (#283): the container creates no instance of its own
561
+ * (`ZITADEL_FIRSTINSTANCE_SKIP`). foundry generates an RSA keypair, puts the
562
+ * public half in `ZITADEL_SYSTEMAPIUSERS` (a system API user with
563
+ * SYSTEM_OWNER only), and once the container answers calls ZITADEL's system
564
+ * API `CreateInstance` with a machine owner (IAM_OWNER of the new instance)
565
+ * signed by the private half, which lives in memory for that run only. The
566
+ * owner's PAT comes back once and is handed to
567
+ * {@link PlatformAuthDeps.deliverSecret} under
568
+ * {@link PLATFORM_AUTH_INSTANCE_BEARER_REF}, before anything else uses it. A
569
+ * host builds `zitadelForInstance` as `(url) => createZitadelAdminMinter({
570
+ * url, token: <the secret delivered under that ref> })` plus its own org
571
+ * calls; `zitadelForInstance` is first called after the delivery. No human
572
+ * admin is created: a person who needs console access is added by the host
573
+ * through that bearer.
574
+ *
575
+ * #197: a `cloudflared` connector for this tunnel is run
576
+ * ({@link ensureCloudflaredConnector}), so `hostname` answers.
577
+ */
578
+ export declare function provisionAuthStack(scope: StepScope, deps: PlatformAuthDeps, target: ServiceTarget, input: AuthStackInput): Promise<{
579
+ resourceId?: string;
580
+ }>;
415
581
  /**
416
582
  * The fixed description Stalwart's admin API key is minted under
417
583
  * (`StalwartClient.createApiKey`'s own signature returns only the secret,
@@ -443,6 +609,7 @@ export interface PlatformMailAdminPort {
443
609
  * written nothing else. The caller restarts the app and calls again.
444
610
  */
445
611
  applySettings(input: {
612
+ /** The name the server announces on SMTP: its own hostname, never the relay's MX name. */
446
613
  mailHost: string;
447
614
  /** The platform's own apex domain, `platformMailSettingsObjects`'s `usernameDomain`. */
448
615
  apex: string;
@@ -529,6 +696,99 @@ export interface PlatformMailRuntime {
529
696
  /** Tries at handing the admin key to the host (default 3). */
530
697
  deliverAttempts?: number;
531
698
  }
699
+ /**
700
+ * What {@link provisionMailServer} reads besides the target's names: the
701
+ * platform's declared values for the platform's server, the company's for a
702
+ * company's.
703
+ */
704
+ export interface MailServerInput {
705
+ /** Where the server's web, JMAP and management API answer: `mail.<domain>`. */
706
+ hostname: string;
707
+ /** The domain the server's admin account and default domain belong to. */
708
+ apex: string;
709
+ /** The MX name: the A record `mxRecord` publishes. The server announces `hostname`, not this. */
710
+ mailHost: string;
711
+ /** The Stalwart image tag. */
712
+ imageTag: string;
713
+ /** `host:container` list; empty publishes no host port. */
714
+ hostPorts: string;
715
+ /** The sign-in the server's directory trusts; its `mail` project's id is the audience. */
716
+ issuerUrl: string;
717
+ /** Publish `mailHost` as an unproxied A record at the server's address. `null`: someone else's record serves it. */
718
+ mxRecord: {
719
+ serverIpv4?: string | null;
720
+ } | null;
721
+ project: ProjectSource;
722
+ network: ServiceNetwork;
723
+ }
724
+ /**
725
+ * #169: makes `coolify:app` keyed {@link PLATFORM_MAIL_KEY} true -- Stalwart
726
+ * on Coolify with its own persistent storage, reached at
727
+ * `platform.mail.hostname` through a Cloudflare tunnel for web, JMAP and its
728
+ * management API (mirroring #168's ZITADEL/login apps: TLS terminated at
729
+ * Cloudflare, plain HTTP behind the tunnel) and at `mx.<apex>` over a direct,
730
+ * unproxied `A` record to the Coolify server's own address for SMTP -- raw
731
+ * SMTP cannot ride Cloudflare's HTTP-only tunnel the way a web app does,
732
+ * which is exactly why #169's own "Human steps" asks a person to open ports
733
+ * 25, 465 and 587 on that server's host firewall. Then, once the instance
734
+ * answers, the OIDC directory against the platform's own sign-in and
735
+ * foundry's admin API key, minted once and handed to
736
+ * {@link PlatformMailDeps.deliverSecret}.
737
+ *
738
+ * Every part is safe to run again after a throw partway, the same rules
739
+ * {@link provisionPlatformAuth} follows: each id is recorded (and persisted)
740
+ * as soon as its resource exists, the app is deployed on every run that
741
+ * needs it, and the admin key is delivered before the guard that skips the
742
+ * mint is recorded. Unlike ZITADEL's `ZITADEL_ADMIN_PASSWORD`, the recovery
743
+ * credential this step generates (`STALWART_RECOVERY_ADMIN`) is read back
744
+ * from Coolify's own env store on every run rather than held only in memory
745
+ * -- `CoolifyPort.listEnvs`'s own doc comment says these values come back
746
+ * live -- so a retry after a delivery failure re-authenticates with the same
747
+ * credential instead of losing it.
748
+ *
749
+ * **Why not `@wtfalch/email/postmaster`'s `mailObjects`/`applyObjects`**, the
750
+ * instance-bootstrap helper `wtfalch-manage` already uses for the hand-built
751
+ * `mail.companyincluded.com`: that plan also wires an `AcmeProvider` and `DnsServer`
752
+ * for Stalwart's own DNS-01 ACME challenge, and an `MtaRoute` that relays
753
+ * every outbound message through the earlier mail provider -- the estate moved transactional
754
+ * mail off it onto Lettermint before this issue (`@wtfalch/mailer` ADR
755
+ * 0006), and this step provisions no Cloudflare DNS token for Stalwart's own
756
+ * ACME either. Calling it as-is would mint an outbound relay this platform
757
+ * does not want and a credential requirement this issue does not cover, so
758
+ * {@link PlatformMailAdminPort.applySettings} is a narrower settings write
759
+ * instead, through `platformMailSettingsObjects`: the default hostname and
760
+ * the OIDC directory only.
761
+ *
762
+ * Known gaps, left for review (like #168's own "the PR stays open for
763
+ * William's review"):
764
+ * - **No ACME/TLS for the SMTP listener.** `mx.<apex>` answers with
765
+ * Stalwart's own self-signed certificate until a follow-up wires a real
766
+ * one (`mailObjects`'s `AcmeProvider`/`DnsServer` shape, minus the earlier relay
767
+ * relay it also carries).
768
+ * - **The Lettermint team token the org app step reads still comes from
769
+ * wherever the host already reads it, not held keys.** Needs
770
+ * `StepExecutionContext` to carry the platform, which `plan-apply.ts`'s
771
+ * own doc comment already scoped out of #167 as more than a small change
772
+ * -- a follow-up issue, not this one. (#205 removed the org step
773
+ * `coolify:mail`'s own fallback to the hand-built mail
774
+ * instance -- `diff.ts`'s `planMail` already blocks the step until the
775
+ * platform's own mail is applied, so the fallback only ever served a
776
+ * plan recorded before that, and now fails loudly instead.)
777
+ * - **A ZITADEL client's token may not carry the mail project's id in
778
+ * `aud` by default.** #346's own "Human steps": confirm this on the first
779
+ * live run, and if it does not, the client must request the mail
780
+ * project's audience scope.
781
+ * - **The admin API key is minted with `'inherit'` permissions**, not a
782
+ * narrower set scoped to only what `applySettings` needs.
783
+ * - **The undelivered-key guard records before `createApiKey` mints**, not
784
+ * after -- so a crash between the two records an id (`admin.createApiKey`
785
+ * has not even run) that no key was ever minted for. Harmless today
786
+ * (the guard only ever blocks a retry and names an id to revoke by), but
787
+ * worth tightening if `createApiKey` ever starts returning its own id.
788
+ */
789
+ export declare function provisionMailServer(scope: StepScope, deps: PlatformMailDeps, target: ServiceTarget, input: MailServerInput): Promise<{
790
+ resourceId?: string;
791
+ }>;
532
792
  /**
533
793
  * #211: the broker cannot start without `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`,
534
794
  * `LETTERMINT_API_TOKEN` and `AUTH_APPS`. `provisionPlatformAuth` already
@@ -811,8 +1071,6 @@ export interface PlatformAiRuntime {
811
1071
  /** Pause between readiness polls (default 5 seconds). */
812
1072
  readyIntervalMs?: number;
813
1073
  }
814
- /** The ref the AI management key is delivered under; the host's `AiOrganisationPort` adapter reads it back. */
815
- export declare const PLATFORM_AI_MANAGEMENT_KEY_REF = "ai:platform:managementKey";
816
1074
  /**
817
1075
  * What Archon's management key may do in ai: exactly `ai`'s own
818
1076
  * `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
@@ -994,6 +1252,8 @@ export interface PlatformFilesRuntime {
994
1252
  readyTimeoutMs?: number;
995
1253
  /** Pause between readiness polls (default 5 seconds). */
996
1254
  readyIntervalMs?: number;
1255
+ /** Called with every secret this step reads or mints, so the host's run can redact it. */
1256
+ onSecret?: (value: string) => void;
997
1257
  }
998
1258
  /**
999
1259
  * #171: how the host signs a files management request with the key this step
@@ -1007,10 +1267,74 @@ export interface PlatformFilesRuntime {
1007
1267
  */
1008
1268
  export declare const FILES_MANAGEMENT_ISSUER = "foundry";
1009
1269
  export declare const FILES_MANAGEMENT_KEY_ID = "foundry-1";
1010
- /** The ref the management private key (PKCS#8 PEM) is delivered under. */
1011
- export declare const FILES_MANAGEMENT_KEY_REF = "files:platform:managementKey";
1012
1270
  /** The ref the files host's introspection client secret is delivered under. */
1013
1271
  export declare const FILES_INTROSPECTION_SECRET_REF = "zitadel:platform:files:introspection";
1272
+ export declare const PLATFORM_FILES_DB_NAME = "platform-files-db";
1273
+ /** The two Coolify apps files' `apps/host/README.md` ("Deploying on Coolify") names. */
1274
+ export declare const FILES_MIGRATE_APP_NAME = "files-migrate";
1275
+ export declare const FILES_RUNNER_APP_NAME = "files";
1276
+ /**
1277
+ * The runner's fixed `custom_internal_name`: the name another container on
1278
+ * the Coolify network reaches it by, and so the host part of
1279
+ * {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY}'s recorded address.
1280
+ */
1281
+ export declare const FILES_RUNNER_INTERNAL_NAME = "platform-files";
1282
+ export interface FilesHostInput {
1283
+ /** The runner's `APP_URL` host, the management verifier's audience. */
1284
+ hostname: string;
1285
+ gitRef: string;
1286
+ project: ProjectSource;
1287
+ /** `AUTH_ISSUER`, and the sign-in instance the files ZITADEL project is made in. */
1288
+ issuer: string;
1289
+ /** The ZITADEL organisation the files project lives in: found or made by name, or already known by id. */
1290
+ zitadelOrg: {
1291
+ name: string;
1292
+ } | {
1293
+ id: string;
1294
+ };
1295
+ /** The files ZITADEL project (its id is the person-token audience) and the scope key its id is recorded under. */
1296
+ zitadelProject: {
1297
+ name: string;
1298
+ scopeKey: string;
1299
+ };
1300
+ /** {@link filesShareDomains} of the orgs this host serves: each is a runner domain and a public host. */
1301
+ shareDomains: readonly string[];
1302
+ /** An own org's `AUTHZ_BOOTSTRAP_OPERATOR`: the platform owner's id, from the platform's recorded state. */
1303
+ bootstrapOperator?: string;
1304
+ /** An own org's keys credential id: recorded as the guard once both apps carry the keyring. */
1305
+ keysGuardId?: string;
1306
+ }
1307
+ /** The credential id of the keyring an own files host runs on; recorded once both apps carry it. */
1308
+ export declare const OWN_FILES_KEYS_SCOPE_KEY = "files_keys_id";
1309
+ /** The credential id minted and not yet confirmed delivered. */
1310
+ export declare const OWN_FILES_KEYS_UNDELIVERED_SCOPE_KEY = "files_keys_undelivered";
1311
+ /**
1312
+ * An own files host's keyring, before its env writes. Both refs held: those values, no mint (a rerun
1313
+ * after a failed run resumes here). Nothing held: one mint, which is checked, registered as a
1314
+ * secret, recorded by id as undelivered, then delivered to both refs. A re-mint is harmless: it
1315
+ * yields an equivalent credential. Returns the guard id for {@link FilesHostInput.keysGuardId}.
1316
+ */
1317
+ export declare function resolveOwnFilesKeys(scope: StepScope, deps: {
1318
+ deliverSecret: PlatformFilesDeps['deliverSecret'];
1319
+ readSecret?: (ref: string) => Promise<string | null>;
1320
+ runtime?: PlatformFilesDeps['runtime'];
1321
+ }, target: ServiceTarget, mint: () => Promise<FilesKeysMinted>, workerUrl: string, label: string): Promise<{
1322
+ keys: {
1323
+ workerUrl: string;
1324
+ credential: string;
1325
+ verifyKeys: string;
1326
+ };
1327
+ guardId: string;
1328
+ }>;
1329
+ /**
1330
+ * The body of {@link provisionPlatformFiles} for any files host: the
1331
+ * platform's, or an own org's. Every name, the internal name and the two
1332
+ * held-key refs come from `target`; the project, sign-in and ZITADEL home
1333
+ * come from `input`. See {@link provisionPlatformFiles} for what the step does.
1334
+ */
1335
+ export declare function provisionFilesHost(scope: StepScope, deps: PlatformFilesDeps, target: ServiceTarget, input: FilesHostInput): Promise<{
1336
+ resourceId?: string;
1337
+ }>;
1014
1338
  export interface PlatformEdgeRuntime {
1015
1339
  fetch?: typeof fetch;
1016
1340
  sleep?: (ms: number) => Promise<void>;
@@ -1088,6 +1412,20 @@ export interface PlatformHcDeps {
1088
1412
  /** Clock, sleep and fetch for the readiness wait; real ones by default. */
1089
1413
  runtime?: PlatformHcRuntime;
1090
1414
  }
1415
+ export declare function filesKeys(deps: PlatformFilesDeps | undefined): {
1416
+ workerUrl: string;
1417
+ credential: string;
1418
+ verifyKeys: string;
1419
+ } | null;
1420
+ /**
1421
+ * #380: the files host's own checks on what the host supplies, mirrored from
1422
+ * `apps/host/src/lib/authz/files-host.ts` (`R2_ENDPOINT`: a bare https origin)
1423
+ * and `src/lib/keys/config.ts` (`KEYS_VERIFY_KEYS`: 1 or 2 comma-separated
1424
+ * base64url values, each exactly 32 bytes), so a bad value blocks the step
1425
+ * naming the field instead of deploying a host that never turns healthy.
1426
+ * Never echoes the value.
1427
+ */
1428
+ export declare function filesDepsInvalid(deps: PlatformFilesDeps | undefined): string | null;
1091
1429
  /**
1092
1430
  * #174: everything {@link provisionPlatformIntegrations} needs beyond
1093
1431
  * {@link PlatformStepExecutionContext} -- a bundle, like
@@ -1279,6 +1617,16 @@ export declare class ProvidersPlatformCoolifyReader implements PlatformCoolifyRe
1279
1617
  constructor(client: Pick<CoolifyPort, 'getApplication' | 'getDatabase' | 'listDatabaseBackups' | 'listEnvs' | 'raw'>, platformStore: Pick<PlatformStore, 'getServiceIds'>);
1280
1618
  listResources(): Promise<LiveResource[]>;
1281
1619
  }
1620
+ /**
1621
+ * `findZoneByApex` returns the longest suffix match, so for a subdomain it
1622
+ * answers with the parent zone. The platform's zone is its apex exactly;
1623
+ * any other zone is never adopted or reported as the platform's.
1624
+ */
1625
+ export declare function exactZone<Z extends {
1626
+ name: string;
1627
+ }>(client: {
1628
+ findZoneByApex(domain: string): Promise<Z | undefined>;
1629
+ }, apex: string): Promise<Z | undefined>;
1282
1630
  /** Scope id (no `:`): the share domains the runner's Coolify `domains` was last set to, comma-joined. */
1283
1631
  export declare const PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY = "platform_files_share_domains";
1284
1632
  /**