@wtfalch/foundry 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/bootstrap/declare.d.ts +3 -1
  2. package/dist/bootstrap/declare.js +6 -2
  3. package/dist/bootstrap/versions.d.ts +17 -0
  4. package/dist/bootstrap/versions.js +12 -0
  5. package/dist/calls-sfu.d.ts +5 -4
  6. package/dist/calls-sfu.js +6 -4
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js +1 -1
  9. package/dist/migrations/0022_chat_enabled.sql +6 -0
  10. package/dist/migrations/0023_hosting_app_env.sql +8 -0
  11. package/dist/migrations/0024_audit_events.sql +211 -0
  12. package/dist/migrations/0025_audit_display.sql +147 -0
  13. package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
  14. package/dist/migrations/0027_audit_chain.sql +170 -0
  15. package/dist/migrations/0028_audit_rls.sql +111 -0
  16. package/dist/org.d.ts +24 -0
  17. package/dist/org.js +13 -0
  18. package/dist/plan/agora.d.ts +21 -0
  19. package/dist/plan/agora.js +23 -0
  20. package/dist/plan/calls-matrix.d.ts +2 -2
  21. package/dist/plan/calls-matrix.js +3 -2
  22. package/dist/plan/cloudflared-connector.d.ts +3 -2
  23. package/dist/plan/cloudflared-connector.js +5 -3
  24. package/dist/plan/diff.d.ts +15 -2
  25. package/dist/plan/diff.js +306 -41
  26. package/dist/plan/executor.d.ts +73 -28
  27. package/dist/plan/executor.js +217 -55
  28. package/dist/plan/index.d.ts +2 -0
  29. package/dist/plan/index.js +1 -0
  30. package/dist/plan/login-host.d.ts +64 -0
  31. package/dist/plan/login-host.js +52 -0
  32. package/dist/plan/plan-apply.d.ts +21 -8
  33. package/dist/plan/plan-apply.js +10 -9
  34. package/dist/plan/platform-diff.d.ts +23 -1
  35. package/dist/plan/platform-diff.js +35 -0
  36. package/dist/plan/platform-providers.d.ts +1 -1
  37. package/dist/plan/platform-providers.js +71 -16
  38. package/dist/plan/providers-readers.js +44 -1
  39. package/dist/store/audit.d.ts +40 -4
  40. package/dist/store/audit.js +114 -29
  41. package/dist/store/postgres.js +4 -1
  42. package/dist/store/schema.d.ts +37 -1
  43. package/dist/store/schema.js +7 -0
  44. package/package.json +5 -3
  45. package/versions.json +8 -0
@@ -1,10 +1,12 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
3
  import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
4
- import { AGORA_CLIENT_ID_SCOPE_KEY, agoraHostnameFor, desiredAgoraEnv, } from './agora.js';
4
+ import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, agoraHostnameFor, desiredAgoraEnv, } from './agora.js';
5
5
  import { MAS_IMAGE, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
6
+ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
7
+ import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, } from './login-host.js';
6
8
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
7
- import { ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, } from './platform-diff.js';
9
+ import { APP_ENV_APPLIED_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, resolveAppEnv, } from './platform-diff.js';
8
10
  // ---------------------------------------------------------------------------
9
11
  // Errors
10
12
  // ---------------------------------------------------------------------------
@@ -228,27 +230,6 @@ const AGORA_APP_DOCKERFILE = 'apps/agora/Dockerfile';
228
230
  * restated rather than shared across the two files.
229
231
  */
230
232
  const LOG_DIR = '/app/logs';
231
- /**
232
- * The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
233
- * not a `system:kind:key` resource id (that slot already holds the app's
234
- * `appId`, `zitadelApp`'s own `resourceId`) and not a secret (`deliverSecret`
235
- * is for that), so a flat scope id like `zitadel_org`/`zitadel_project`
236
- * above. A level-1 org declares exactly one web `issuerApps` entry for its
237
- * one standard app, so one flat key is enough; an org with more than one web
238
- * app would need a richer key, not built here (Finding, this campaign's
239
- * report).
240
- */
241
- const APP_CLIENT_ID_SCOPE_KEY = 'zitadel_app_client_id';
242
- /**
243
- * #139: the `cms` app's own ZITADEL web client's `clientId`, recorded
244
- * separately from {@link APP_CLIENT_ID_SCOPE_KEY} -- that key belongs to the
245
- * org's own hosted app (`diff.ts`'s `planIssuerApps`), a different
246
- * `zitadel:app` step from the fixed `cms` client `diff.ts`'s `planCmsClient`
247
- * plans. `cmsSite` reads this key to push `AUTH_CLIENT_ID` onto the cms app,
248
- * the same way `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` for the hosted
249
- * app.
250
- */
251
- const CMS_CLIENT_ID_SCOPE_KEY = 'cms_client_id';
252
233
  /**
253
234
  * `cmsSite` provisions two applications from one `cms:site` step, but
254
235
  * `execute`'s own return value auto-records only one `resourceId`, under
@@ -290,6 +271,13 @@ const CMS_DATABASE_SCOPE_KEY = 'coolify_cms_database';
290
271
  * `resend_mail_domain`.
291
272
  */
292
273
  const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
274
+ /**
275
+ * #235: the Coolify app running `cloudflared` for this org's own tunnel --
276
+ * one connector per org tunnel (Decided, William, 2026-10-01), the same
277
+ * `ensureCloudflaredConnector` every platform tunnel step already calls
278
+ * (`platform-providers.ts`), never a shared tunnel with appended ingress.
279
+ */
280
+ const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
293
281
  /**
294
282
  * #162: the owner's own mailbox, created once inside `coolify:mail` after
295
283
  * `applyOrgMail` succeeds, alongside {@link MAIL_DOMAIN_SCOPE_KEY} -- another
@@ -434,6 +422,8 @@ export class FoundryStepExecutor {
434
422
  return this.zitadelIssuer(action, org, context);
435
423
  case 'zitadel:owner':
436
424
  return this.zitadelOwner(action, org, context);
425
+ case 'zitadel:login-host':
426
+ return this.zitadelLoginHost(action, org, context);
437
427
  case 'zitadel:app':
438
428
  return this.zitadelApp(action, org, context);
439
429
  case 'zitadel:customers':
@@ -603,6 +593,54 @@ export class FoundryStepExecutor {
603
593
  });
604
594
  return { resourceId: userId };
605
595
  }
596
+ /**
597
+ * `plan/diff.ts`'s `planLoginHost` (#267): adds the org's own company
598
+ * login host (`action.desired.host`) to the ZITADEL instance's trusted
599
+ * domains, writes zitadel-login's binding, then repoints the apps whose
600
+ * client id that binding holds. Apps created later are not in it yet:
601
+ * the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
602
+ */
603
+ async zitadelLoginHost(action, org, context) {
604
+ const recorded = context.recordedIds();
605
+ const zitadelOrgId = recorded.zitadel_org;
606
+ if (!zitadelOrgId) {
607
+ throw new Error(`zitadel:login-host for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
608
+ }
609
+ // `planLoginHost` blocks the step while this port is not wired.
610
+ const bindings = this.ports.loginHostBindings;
611
+ if (!bindings) {
612
+ throw new Error(`zitadel:login-host for ${org.slug}: the loginHostBindings port is not wired`);
613
+ }
614
+ const host = action.desired.host;
615
+ await this.ports.zitadel.ensureTrustedDomain(host);
616
+ // zitadel-login's own routing table, written before any app is repointed
617
+ // so no app ever points at a host with no binding.
618
+ const clientIds = loginHostClientIds(recorded);
619
+ await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
620
+ // Only apps whose client id the binding now holds: any other app in the
621
+ // project would lose sign-in. The rest move on a later run, once the
622
+ // recorded client ids differ from the binding and replan this step.
623
+ const boundAppIds = new Set([
624
+ ...(recorded[APP_CLIENT_ID_SCOPE_KEY]
625
+ ? org.hosting.issuerApps.filter((a) => a.kind === 'web').map((a) => a.name)
626
+ : []),
627
+ ...(recorded[CMS_CLIENT_ID_SCOPE_KEY] ? ['cms'] : []),
628
+ ...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
629
+ ].map((name) => recorded[`zitadel:app:${name}`]));
630
+ const projectId = recorded.zitadel_project;
631
+ if (projectId) {
632
+ const apps = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
633
+ for (const app of apps) {
634
+ if (app.state === 'APP_STATE_REMOVED' || !boundAppIds.has(app.id))
635
+ continue;
636
+ await this.ports.zitadel.updateOidcAppLoginBaseUri(zitadelOrgId, projectId, app.id, `https://${host}/`);
637
+ }
638
+ }
639
+ // Last, so a crash above reruns the whole step. A client id recorded
640
+ // after this point differs from it and replans the step next run.
641
+ context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
642
+ return { resourceId: host };
643
+ }
606
644
  /**
607
645
  * `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
608
646
  * in through a second ZITADEL org, `<slug> customers`, with
@@ -827,11 +865,17 @@ export class FoundryStepExecutor {
827
865
  // resource id is already recorded by the time this runs -- but this also
828
866
  // covers the database being turned on after the app already exists (the
829
867
  // app step still ran this apply, found the app by name, and lands here).
868
+ let dbPush = { pushed: false, changed: false };
830
869
  if (org.hosting.database) {
831
870
  const dbId = context.recordedIds()[`coolify:database:${action.key}`];
832
- if (dbId)
833
- await this.pushDatabaseUrl(appId, dbId);
871
+ if (dbId) {
872
+ dbPush = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
873
+ }
834
874
  }
875
+ // Restart when the push changed something, or when no earlier run got as
876
+ // far as restarting after one (marker missing): a crash between the push
877
+ // and the restart leaves the env right and the container stale.
878
+ const dbNeedsRestart = dbPush.pushed && (dbPush.changed || !context.recordedIds()[DB_ENV_APPLIED_SCOPE_KEY]);
835
879
  // Every app keeps its own logs across a redeploy (#75). Unconditional,
836
880
  // like the env push below: found by mount path before create, so a
837
881
  // re-run (or the drift step `diff.ts`'s `planApp` plans for an app that
@@ -843,7 +887,17 @@ export class FoundryStepExecutor {
843
887
  // The app receives whatever it needs to sign in (gap P2). Unconditional,
844
888
  // unlike the database push above: every app has a hostname, and an
845
889
  // upsert of the same value on a later apply is a no-op, not a change.
846
- await this.pushAppEnv(appId, org, context);
890
+ const appEnv = await this.pushAppEnv(appId, org, context);
891
+ // Rule 4 (#239, #243): a container reads its env only at start, so an app
892
+ // that was already there is restarted, once, when its database URLs or its
893
+ // declared appEnv changed.
894
+ if ((appEnv.changed || dbNeedsRestart) && existing)
895
+ await this.ports.coolify.restart(appId);
896
+ // Recorded only after the restart succeeded: a failed restart leaves the
897
+ // old value, so the next plan replans (rule 3). The db marker is not a URL.
898
+ context.recordScopeId(APP_ENV_APPLIED_SCOPE_KEY, appEnv.applied);
899
+ if (dbPush.pushed)
900
+ context.recordScopeId(DB_ENV_APPLIED_SCOPE_KEY, '1');
847
901
  // #140 rework (2026-09-28 lettermint-org-mail swap): the hosted app's own
848
902
  // Lettermint project, domain restriction and `MAIL_FROM` (only the app
849
903
  // started by this same step, per the design's "only apps that send mail
@@ -927,39 +981,80 @@ export class FoundryStepExecutor {
927
981
  // (`coolifyApp`'s own push handles the more common order, database
928
982
  // before app on the same apply -- `diff.ts`'s `computePlanDetail`).
929
983
  const appId = context.recordedIds()[`coolify:app:${action.key}`];
930
- if (appId)
931
- await this.pushDatabaseUrl(appId, dbId);
984
+ if (appId) {
985
+ const push = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
986
+ if (push.pushed) {
987
+ if (push.changed || !context.recordedIds()[DB_ENV_APPLIED_SCOPE_KEY]) {
988
+ await this.ports.coolify.restart(appId);
989
+ }
990
+ context.recordScopeId(DB_ENV_APPLIED_SCOPE_KEY, '1');
991
+ }
992
+ }
932
993
  return { resourceId: dbId };
933
994
  }
934
995
  /**
935
- * Pushes the database's connection string into the app's environment as
936
- * `DATABASE_URL` -- never also as `DATABASE_URL_OWNER` (Decision, this
937
- * campaign's report). Archon's own boot script
938
- * (`archon/scripts/migrate-and-start.sh`) treats a bare `DATABASE_URL` with
939
- * no `DATABASE_URL_OWNER` as "before the split": it migrates and serves as
940
- * the one role it has, which is exactly what a database Coolify just
941
- * created (one superuser role, no separate runtime role yet) is. Setting
942
- * both to the *same* superuser URL would instead take the split branch --
943
- * `db-role-guard.sh` then refuses to boot, since `DATABASE_URL_OWNER` being
944
- * set means it expects `DATABASE_URL` to connect as a distinct,
945
- * non-superuser runtime role (`<database>_rt`), which nothing here creates.
946
- * Minting that role's own password and URL is a provisioning step of its
947
- * own, not built here (Finding, this campaign's report).
996
+ * Pushes the database's connection string into the app's environment,
997
+ * split the same way `cmsSite`'s own database is (#117, #239): the
998
+ * superuser connection as `DATABASE_URL_OWNER`, and a distinct, restricted
999
+ * `<dbName>_rt` role's connection as `DATABASE_URL`. Archon's and manage's
1000
+ * own boot scripts (`db-role-guard.sh`) refuse to start against a
1001
+ * superuser `DATABASE_URL`, and lokessmie's refuses to start without
1002
+ * `DATABASE_URL_OWNER` at all -- a bare superuser `DATABASE_URL` with no
1003
+ * `DATABASE_URL_OWNER` is "before the split" there too.
948
1004
  *
949
- * The connection string is a live credential
950
- * (`postgres://user:password@host/db`): it is read from
951
- * `CoolifyPort.getDatabase` and handed straight to `bulkUpsertEnvs` and
952
- * nowhere else in this class -- never returned, never recorded in
953
- * `serviceIds` (hence never in the store or an audit record), never logged,
954
- * never interpolated into a thrown error.
1005
+ * Both connection strings are live credentials
1006
+ * (`postgres://user:password@host/db`): read from `CoolifyPort.getDatabase`
1007
+ * (or minted here, for the runtime one) and handed straight to
1008
+ * `bulkUpsertEnvs` and nowhere else in this class -- never returned, never
1009
+ * recorded in `serviceIds` (hence never in the store or an audit record),
1010
+ * never logged, never interpolated into a thrown error.
955
1011
  */
956
- async pushDatabaseUrl(appId, dbId) {
1012
+ async pushDatabaseUrl(appId, dbId, dbName) {
957
1013
  const db = await this.ports.coolify.getDatabase(dbId);
1014
+ // Nothing to push; `planApp` reports this as blocked from the live reader.
958
1015
  if (!db.internal_db_url)
959
- return;
960
- await this.ports.coolify.bulkUpsertEnvs(appId, [
961
- { key: 'DATABASE_URL', value: db.internal_db_url },
962
- ]);
1016
+ return { pushed: false, changed: false };
1017
+ const envs = [
1018
+ { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
1019
+ ];
1020
+ const runtimeUrl = await this.ensureAppRuntimeUrl(appId, db.internal_db_url, dbName);
1021
+ if (runtimeUrl)
1022
+ envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
1023
+ // True when a value actually changed, so the caller restarts the app only
1024
+ // then (the owner URL is upserted on every apply).
1025
+ const ownerBefore = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL_OWNER' && !e.is_preview)?.value;
1026
+ await this.ports.coolify.bulkUpsertEnvs(appId, envs);
1027
+ return { pushed: true, changed: runtimeUrl !== null || ownerBefore !== db.internal_db_url };
1028
+ }
1029
+ /**
1030
+ * `null` once the app already has a `DATABASE_URL` -- never rotated, the
1031
+ * same rule `ensureCmsRuntimeUrl` follows and for the same reason: the
1032
+ * app's own boot script creates or updates the live `<dbName>_rt` Postgres
1033
+ * role from exactly the username/password this method mints, once:
1034
+ * overwriting `DATABASE_URL` on a later apply would leave that
1035
+ * already-created role's real password out of sync with whatever foundry
1036
+ * hands the app next. See `ensureCmsRuntimeUrl`'s own doc comment for the
1037
+ * parsing and encoding choices this mirrors exactly.
1038
+ */
1039
+ async ensureAppRuntimeUrl(appId, dbInternalUrl, dbName) {
1040
+ const envs = await this.ports.coolify.listEnvs(appId);
1041
+ // An existing `DATABASE_URL` that is still the superuser string is what an
1042
+ // app got before the split (#239): treat it as absent so the runtime role
1043
+ // is minted and replaces it, or the app's db-role-guard refuses to boot.
1044
+ const already = envs.some((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value !== dbInternalUrl);
1045
+ if (already)
1046
+ return null;
1047
+ let host;
1048
+ let pathname;
1049
+ let search;
1050
+ try {
1051
+ ({ host, pathname, search } = new URL(dbInternalUrl));
1052
+ }
1053
+ catch {
1054
+ return null;
1055
+ }
1056
+ const password = randomBytes(24).toString('base64url');
1057
+ return `postgres://${dbName}_rt:${password}@${host}${pathname}${search}`;
963
1058
  }
964
1059
  /**
965
1060
  * Pushes what the org's app needs to sign a user in through ZITADEL (gap
@@ -977,8 +1072,10 @@ export class FoundryStepExecutor {
977
1072
  * (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
978
1073
  * reason. `AUTH_APP_KEY` (#206 -- which broker an org's app registers with
979
1074
  * is its own design question, left out of #204's own "How" rather than
980
- * guessed) and DATABASE_URL_OWNER (a runtime-role split not built yet,
981
- * `pushDatabaseUrl`'s own doc comment) are out of scope here (task brief).
1075
+ * guessed) is out of scope here (task brief). `DATABASE_URL_OWNER`/
1076
+ * `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
1077
+ * that call runs only once `hosting.database` is declared and
1078
+ * provisioned, where this one is unconditional (#239).
982
1079
  */
983
1080
  async pushAppEnv(appId, org, context) {
984
1081
  const recorded = context.recordedIds();
@@ -1009,7 +1106,21 @@ export class FoundryStepExecutor {
1009
1106
  const cookieSecret = await this.ensureAuthCookieSecret(appId);
1010
1107
  if (cookieSecret)
1011
1108
  envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
1109
+ // #243: the app's own declared map from its env var names to values the
1110
+ // plan already mints for this org (Files, AI, mail) -- each bespoke app
1111
+ // names these differently (lokessmie uses STORAGE_*/AI_*). Nothing is
1112
+ // minted here; a source with no value yet (its own step has not run,
1113
+ // or has not run for this apply) is simply omitted, the same way every
1114
+ // other optional entry above is.
1115
+ const appEnv = resolveAppEnv(org, recorded).values;
1116
+ for (const [key, value] of Object.entries(appEnv))
1117
+ envs.push({ key, value });
1012
1118
  await this.ports.coolify.bulkUpsertEnvs(appId, envs);
1119
+ // `coolifyApp` records `applied` once the restart succeeded, so `planApp`
1120
+ // replans this step when the declared map changes (rule 3). Ids and a
1121
+ // domain, never a secret.
1122
+ const applied = JSON.stringify(appEnv);
1123
+ return { changed: applied !== (recorded[APP_ENV_APPLIED_SCOPE_KEY] ?? '{}'), applied };
1013
1124
  }
1014
1125
  /**
1015
1126
  * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
@@ -2081,6 +2192,19 @@ export class FoundryStepExecutor {
2081
2192
  await this.ports.cloudflare.putTunnelConfig(tunnelId, {
2082
2193
  ingress: [{ service: 'http_status:404' }],
2083
2194
  });
2195
+ // #235: park stops the connector too -- an ingress with nothing
2196
+ // routed is still a running container dialed into the tunnel.
2197
+ const connectorAppId = context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
2198
+ if (connectorAppId) {
2199
+ try {
2200
+ await this.ports.coolify.raw('POST', `/applications/${connectorAppId}/stop`);
2201
+ }
2202
+ catch (error) {
2203
+ // Already gone (destroyed out of band): nothing left to stop.
2204
+ if (!isNotFoundError(error))
2205
+ throw error;
2206
+ }
2207
+ }
2084
2208
  return { resourceId: tunnelId };
2085
2209
  }
2086
2210
  const tunnelName = `${org.slug}-tunnel`;
@@ -2110,6 +2234,29 @@ export class FoundryStepExecutor {
2110
2234
  await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
2111
2235
  ingress: [...ingress, { service: 'http_status:404' }],
2112
2236
  });
2237
+ // #235: a connector for this org's own tunnel, the same call every
2238
+ // platform tunnel step already makes (Decided, William, 2026-10-01:
2239
+ // one connector per org tunnel, never a shared tunnel with appended
2240
+ // ingress). Without this, the tunnel exists and is configured but
2241
+ // nothing is dialed into it, so `hostname` answers with a Cloudflare
2242
+ // error -- #197's own gap, for an org's tunnel rather than a platform
2243
+ // one.
2244
+ const projectUuid = await this.ensureCoolifyProject(org, context);
2245
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2246
+ await ensureCloudflaredConnector({
2247
+ coolify: this.ports.coolify,
2248
+ cloudflare: this.ports.cloudflare,
2249
+ tunnelId: tunnel.id,
2250
+ appName: `${org.slug}-cloudflared`,
2251
+ projectUuid,
2252
+ environment,
2253
+ resolveServer: () => this.resolveServer(),
2254
+ recordedConnectorAppId: context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY] ?? null,
2255
+ recordAppId: async (appId) => {
2256
+ context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, appId);
2257
+ await context.persistScopeIds();
2258
+ },
2259
+ });
2113
2260
  return { resourceId: tunnel.id };
2114
2261
  }
2115
2262
  /**
@@ -2507,9 +2654,24 @@ export class FoundryStepExecutor {
2507
2654
  const { org, recordedIds } = context;
2508
2655
  try {
2509
2656
  switch (`${target.system}:${target.kind}`) {
2510
- case 'cloudflare:tunnel':
2657
+ case 'cloudflare:tunnel': {
2658
+ // #235: the connector app first, its own 404 handled locally --
2659
+ // a retry where this already succeeded must still reach
2660
+ // `deleteTunnel` below, which the outer catch's own 404 handling
2661
+ // would otherwise short-circuit before it ran.
2662
+ const connectorAppId = recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
2663
+ if (connectorAppId) {
2664
+ try {
2665
+ await this.ports.coolify.deleteApplication(connectorAppId);
2666
+ }
2667
+ catch (error) {
2668
+ if (!isNotFoundError(error))
2669
+ throw error;
2670
+ }
2671
+ }
2511
2672
  await this.ports.cloudflare.deleteTunnel(target.id);
2512
2673
  return;
2674
+ }
2513
2675
  case 'cloudflare:access':
2514
2676
  await this.ports.cloudflare.deleteAccessApp(target.id);
2515
2677
  return;
@@ -24,6 +24,8 @@ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCop
24
24
  export type { ScopeResolver, RecordedIds } from './providers-readers.js';
25
25
  export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
26
26
  export type { AgoraHostPort, AgoraHostValues } from './agora.js';
27
+ export type { LoginHostBinding, LoginHostBindingsPort } from './login-host.js';
28
+ export { loginHostnameFor } from './login-host.js';
27
29
  export type { MailIdentityApp, MailIdentityAudienceCheck } from './mail-identity.js';
28
30
  export { checkMailIdentityAudience } from './mail-identity.js';
29
31
  export type { MailAllowlistApp } from './mail-identity-allowlist.js';
@@ -12,6 +12,7 @@ export { createZitadelAdminMinter } from './zitadel-machine-user.js';
12
12
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
13
13
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
14
14
  export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
15
+ export { loginHostnameFor } from './login-host.js';
15
16
  export { checkMailIdentityAudience } from './mail-identity.js';
16
17
  export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
17
18
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
@@ -0,0 +1,64 @@
1
+ import type { DeclaredOrg } from '../org.js';
2
+ /**
3
+ * #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
4
+ * need to agree on for an org's own company login host -- the hostname and
5
+ * the shape of zitadel-login's own routing entry -- the same split
6
+ * `agora.ts` already makes for Agora's own hostname and env.
7
+ *
8
+ * Decided in https://github.com/wtfalch/auth/issues/137 (William,
9
+ * 2026-10-01): foundry owns company sign-in setup through
10
+ * `@wtfalch/providers`, not `auth`'s own `scripts/namespace-provisioner.mjs`.
11
+ * `auth/docs/company-login-hosts.md` is the enforcement side this hostname
12
+ * and binding feed.
13
+ */
14
+ /**
15
+ * `auth.<domain>` once the org has its own domain and `issuer.usesDomain` is on, `<slug>-auth.oikos.host`
16
+ * otherwise -- the same per-tier shape `agora.ts`'s own `agoraHostnameFor`
17
+ * uses for Agora, and the two tiers #267's issue names directly
18
+ * ("auth.<domain>" on the org's own domain, "<slug>-auth.oikos.host" for
19
+ * tier 1, riding the shared `*.oikos.host` wildcard tunnel the same way
20
+ * `<slug>-agora.oikos.host` does). `../slug.ts`'s
21
+ * `RESERVED_AUTH_AND_ENVIRONMENTS` already reserves the `auth` *slug* so no
22
+ * org's own `<slug>.oikos.host` can collide with this subdomain, the same
23
+ * way `AGORA_SUFFIX` reserves `-agora`.
24
+ */
25
+ export declare function loginHostnameFor(org: DeclaredOrg): string;
26
+ /**
27
+ * One entry in zitadel-login's own `LOGIN_HOST_BINDINGS`
28
+ * (`auth/docs/company-login-hosts.md`) -- a single-line JSON document naming
29
+ * every company-bound host, its ZITADEL organization and the client ids
30
+ * allowed to use it. `host` is {@link loginHostnameFor}'s own value;
31
+ * `organizationId` is the org's ZITADEL org id (`executor.ts`'s
32
+ * `zitadelIssuer` recorded it as `zitadel_org`).
33
+ */
34
+ export interface LoginHostBinding {
35
+ host: string;
36
+ organizationId: string;
37
+ clientIds: string[];
38
+ }
39
+ /**
40
+ * Host-supplied write access to zitadel-login's own `LOGIN_HOST_BINDINGS`
41
+ * env (`auth/docs/company-login-hosts.md`'s own "Set `LOGIN_HOST_BINDINGS`
42
+ * on zitadel-login", "today set by hand"). Optional on
43
+ * {@link FoundryStepExecutorPorts} the same way `jobsScheduler`/`mailCopy`
44
+ * are, but without it the login-host step is blocked, not partly applied:
45
+ * isolation between orgs rests on the binding, and an app repointed at a host
46
+ * with no binding cannot sign in. Wiring the port means the host routes to
47
+ * zitadel-login (DNS and ingress), because the first apply after wiring
48
+ * repoints the org's bound apps.
49
+ */
50
+ export interface LoginHostBindingsPort {
51
+ upsertBinding(binding: LoginHostBinding): Promise<void>;
52
+ }
53
+ /**
54
+ * Scope id holding what the binding was last written with
55
+ * ({@link loginBindingState}). Client ids are not secrets. The planner
56
+ * replans the login-host step when the org's recorded client ids no longer
57
+ * match it, so an app created after the first apply is added to the binding
58
+ * on the next run.
59
+ */
60
+ export declare const LOGIN_BINDING_SCOPE_KEY = "login_host_binding";
61
+ /** The org's recorded app client ids (hosted app, cms, agora), sorted. */
62
+ export declare function loginHostClientIds(recorded: Readonly<Record<string, string>>): string[];
63
+ /** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
64
+ export declare function loginBindingState(host: string, clientIds: readonly string[]): string;
@@ -0,0 +1,52 @@
1
+ import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, } from './agora.js';
2
+ /**
3
+ * #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
4
+ * need to agree on for an org's own company login host -- the hostname and
5
+ * the shape of zitadel-login's own routing entry -- the same split
6
+ * `agora.ts` already makes for Agora's own hostname and env.
7
+ *
8
+ * Decided in https://github.com/wtfalch/auth/issues/137 (William,
9
+ * 2026-10-01): foundry owns company sign-in setup through
10
+ * `@wtfalch/providers`, not `auth`'s own `scripts/namespace-provisioner.mjs`.
11
+ * `auth/docs/company-login-hosts.md` is the enforcement side this hostname
12
+ * and binding feed.
13
+ */
14
+ /**
15
+ * `auth.<domain>` once the org has its own domain and `issuer.usesDomain` is on, `<slug>-auth.oikos.host`
16
+ * otherwise -- the same per-tier shape `agora.ts`'s own `agoraHostnameFor`
17
+ * uses for Agora, and the two tiers #267's issue names directly
18
+ * ("auth.<domain>" on the org's own domain, "<slug>-auth.oikos.host" for
19
+ * tier 1, riding the shared `*.oikos.host` wildcard tunnel the same way
20
+ * `<slug>-agora.oikos.host` does). `../slug.ts`'s
21
+ * `RESERVED_AUTH_AND_ENVIRONMENTS` already reserves the `auth` *slug* so no
22
+ * org's own `<slug>.oikos.host` can collide with this subdomain, the same
23
+ * way `AGORA_SUFFIX` reserves `-agora`.
24
+ */
25
+ export function loginHostnameFor(org) {
26
+ // `issuer.usesDomain` is the issuer's opt-in into the org's domain
27
+ // (`org.ts`'s `IssuerConfig`): a domain alone never moves it, so a domain
28
+ // with `usesDomain` false stays on the tier 1 host.
29
+ return org.domain && org.issuer.usesDomain ? `auth.${org.domain}` : `${org.slug}-auth.oikos.host`;
30
+ }
31
+ /**
32
+ * Scope id holding what the binding was last written with
33
+ * ({@link loginBindingState}). Client ids are not secrets. The planner
34
+ * replans the login-host step when the org's recorded client ids no longer
35
+ * match it, so an app created after the first apply is added to the binding
36
+ * on the next run.
37
+ */
38
+ export const LOGIN_BINDING_SCOPE_KEY = 'login_host_binding';
39
+ /** The org's recorded app client ids (hosted app, cms, agora), sorted. */
40
+ export function loginHostClientIds(recorded) {
41
+ return [
42
+ recorded[APP_CLIENT_ID_SCOPE_KEY],
43
+ recorded[CMS_CLIENT_ID_SCOPE_KEY],
44
+ recorded[AGORA_CLIENT_ID_SCOPE_KEY],
45
+ ]
46
+ .filter((id) => Boolean(id))
47
+ .sort();
48
+ }
49
+ /** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
50
+ export function loginBindingState(host, clientIds) {
51
+ return `${host} ${clientIds.join(',')}`;
52
+ }
@@ -17,14 +17,19 @@ export interface AuditActor {
17
17
  /**
18
18
  * The seam onto `@wtfalch/audit`.
19
19
  *
20
- * #19 asks for `@wtfalch/audit` itself, but that package's `record`/`ledger`
21
- * (see `app-template/src/lib/authz/audit.ts`) is bound to one app's Drizzle
22
- * table, session model and closed `EventName`/`ActorClass` vocabulary — none
23
- * of which foundry's SDK package has (it has no database of its own; #18
24
- * owns the store). Adopting it here is more than a small change, so this is
25
- * the interface instead, logged as a Finding on #19 rather than a half
26
- * adoption. A real implementation binds `@wtfalch/audit`'s `ledger.sign` to
27
- * foundry's own vocabulary and table once foundry has one.
20
+ * #19 asked for `@wtfalch/audit` itself, but at the time that package's
21
+ * `record`/`ledger` were bound to one app's Drizzle table, session model and
22
+ * closed `EventName`/`ActorClass` vocabulary -- none of which foundry's SDK
23
+ * package had. This interface was the stand-in, logged as a Finding on #19
24
+ * rather than a half adoption.
25
+ *
26
+ * #232: `@wtfalch/audit` now supports a service with no tenant database of
27
+ * its own (`ledgerVocabulary`, `tenantId: null`, the org as `target`) -- see
28
+ * `store/audit.ts`'s `createPostgresAuditLog`, which binds `ledger.sign` to
29
+ * foundry's own closed vocabulary and `foundry_audit_events`' replacement,
30
+ * `audit_events` (`@wtfalch/audit`'s own table, through migrations
31
+ * 0022-0026). This interface itself is unchanged -- `bootstrap.ts` and
32
+ * `redactingAudit` need no change either.
28
33
  */
29
34
  export interface AuditLog {
30
35
  recordPlan(entry: {
@@ -281,6 +286,13 @@ export interface FoundryPlanApplyOptions {
281
286
  * step never runs (and fails) where no scheduler is deployed.
282
287
  */
283
288
  jobsScheduler?: boolean;
289
+ /**
290
+ * #267: true when the host's executor was built with a `loginHostBindings`
291
+ * port. Optional, and false without it: each org's login host is then
292
+ * reported blocked instead of planned, so nothing is trusted or repointed
293
+ * before zitadel-login's binding can be written.
294
+ */
295
+ loginHostBindings?: boolean;
284
296
  /**
285
297
  * #177: true when the host's executor was built with its own `filesOrganisation`
286
298
  * / `aiOrganisation` port. Without one, the org step needs the platform's
@@ -334,6 +346,7 @@ export declare class FoundryPlanApply implements PlanApply {
334
346
  private readonly actor;
335
347
  private readonly agora;
336
348
  private readonly jobsScheduler;
349
+ private readonly loginHostBindings;
337
350
  private readonly hostFiles;
338
351
  private readonly hostAi;
339
352
  private readonly now;
@@ -78,6 +78,7 @@ export class FoundryPlanApply {
78
78
  actor;
79
79
  agora;
80
80
  jobsScheduler;
81
+ loginHostBindings;
81
82
  hostFiles;
82
83
  hostAi;
83
84
  now;
@@ -92,6 +93,7 @@ export class FoundryPlanApply {
92
93
  this.actor = options.actor;
93
94
  this.agora = options.agora;
94
95
  this.jobsScheduler = options.jobsScheduler ?? false;
96
+ this.loginHostBindings = options.loginHostBindings ?? false;
95
97
  this.hostFiles = options.filesOrganisation ?? false;
96
98
  this.hostAi = options.aiOrganisation ?? false;
97
99
  this.now = options.now ?? (() => new Date());
@@ -351,7 +353,7 @@ export class FoundryPlanApply {
351
353
  this.orgPlatformReadiness(),
352
354
  this.orgAgoraHost(id),
353
355
  ]);
354
- const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness);
356
+ const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, { loginHostBindings: this.loginHostBindings });
355
357
  return { org, plan: toPlan(detail), detail };
356
358
  }
357
359
  /**
@@ -617,12 +619,13 @@ function stepField(action) {
617
619
  *
618
620
  * Three sources, in this order:
619
621
  * - fields no planner step exists for yet, so no apply can make them true:
620
- * SSO, guests and solo when turned on, a bespoke app repo, an adopted app
621
- * in the `repo` form (#7's `#15` comment: "declared but refused by
622
- * `apply`"), and calls or hosting on a domain the org has not recorded
623
- * (#145: calls on the org's own domain now has a planner step, the same
624
- * way hosting's own domain does, so it only fails for that one missing-
625
- * domain reason, not unconditionally);
622
+ * SSO, guests and solo when turned on, an adopted app in the `repo` form
623
+ * (#7's `#15` comment: "declared but refused by `apply`"), and calls or
624
+ * hosting on a domain the org has not recorded (#145: calls on the org's
625
+ * own domain now has a planner step, the same way hosting's own domain
626
+ * does, so it only fails for that one missing-domain reason, not
627
+ * unconditionally). `bespokeAppRepo` is not one of these: `coolify:app`
628
+ * reads it (#240), so it converges with that step like any other field;
626
629
  * - chat's reverse re-key, which the planner surfaces but which is not built
627
630
  * (#7 amendment 3), so it counts as failed even when no step threw;
628
631
  * - on a failed apply, the field of every planned step from the one that
@@ -646,8 +649,6 @@ function failedFields(org, detail, completed, serviceIds) {
646
649
  failed.add('guests.enabled');
647
650
  if (org.solo.enabled)
648
651
  failed.add('solo.enabled');
649
- if (org.bespokeAppRepo !== null)
650
- failed.add('bespokeAppRepo');
651
652
  if (org.adoptedApp?.form === 'repo')
652
653
  failed.add('adoptedApp');
653
654
  // `planCallsService`/`planTunnel`/`planZone` all share this same domain