@wtfalch/foundry 0.41.2 → 0.42.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.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceSchema, ServicesConfig, VersionPin, VersionPinPart, } from './org.js';
2
- export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateVersionPin, validateSite, } from './org.js';
1
+ export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceSchema, ServicesConfig, SignInConfig, VersionPin, VersionPinPart, } from './org.js';
2
+ export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateSignIn, validateVersionPin, validateSite, } from './org.js';
3
3
  export type { DeclaredPlatform, PlatformService, PlatformServiceKey, PlatformSfuService, HeartbeatsProvider, } from './platform.js';
4
4
  export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, validatePlatform, } from './platform.js';
5
5
  import { type PinnedVersions } from './bootstrap/versions.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateVersionPin, validateSite, } from './org.js';
1
+ export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateSignIn, validateVersionPin, validateSite, } from './org.js';
2
2
  export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, validatePlatform, } from './platform.js';
3
3
  // archon#273: the pinned platform service versions the planner deploys, so a
4
4
  // host can show declared against running. Readonly: it is the planner's own
@@ -0,0 +1,6 @@
1
+ -- @wtfalch/foundry: a company's in-app sign-in switch. `signin_in_app` true moves the
2
+ -- people's sign-in onto each app's own address (`/sign-in`); false (default) keeps the
3
+ -- company's login host. Additive. Mirrors src/store/schema.ts's `declaredOrgs`.
4
+
5
+ alter table foundry_declared_orgs
6
+ add column if not exists signin_in_app boolean not null default false;
package/dist/org.d.ts CHANGED
@@ -636,6 +636,21 @@ export interface ServicesConfig {
636
636
  sfuSlot?: number | null;
637
637
  }
638
638
  export declare const SFU_SLOT_MAX = 20;
639
+ /**
640
+ * Per-company sign-in placement. `inApp` (default off) moves the people's sign-in from the
641
+ * company's login host onto each app's own address (`/sign-in` on agora.<domain>, boule.<domain>),
642
+ * through the login that serves that path. Read through `plan/sign-in.ts`'s `inAppSignIn`.
643
+ */
644
+ export interface SignInConfig {
645
+ inApp: boolean;
646
+ }
647
+ /**
648
+ * `null` when `signIn` is valid. The switch needs a company domain whose login host is
649
+ * `auth.<domain>`: a company on `oikos.host` (tier 1) keeps today's flow, so the declaration
650
+ * is refused there rather than silently ignored. A company with its own services is covered: its
651
+ * own sign-in stack gets the second login app.
652
+ */
653
+ export declare function validateSignIn(org: Pick<DeclaredOrg, 'signIn' | 'domain' | 'issuer' | 'services'>): string | null;
639
654
  /** The only reader of `services.own`. Every company runs its own services, the platform's owner company too; a company without a domain runs them on oikos.host. */
640
655
  export declare function ownsServices(org: Pick<DeclaredOrg, 'services'>): boolean;
641
656
  /** The pure part of the `services` rules; that no two orgs share an `sfuSlot` is the store's check. */
@@ -916,6 +931,8 @@ export interface DeclaredOrg {
916
931
  hosting: HostingConfig;
917
932
  /** Absent means `{ own: false }`. Optional so every existing literal and test fixture compiles unchanged, as `calls.versionPin` and `MailConfig.mailApps` do. */
918
933
  services?: ServicesConfig;
934
+ /** Absent means `{ inApp: false }`. Optional so every existing literal and fixture compiles unchanged. */
935
+ signIn?: SignInConfig;
919
936
  /** Parts of this company held on their own version. Absent or null follows the platform. Ignored for the owner company. */
920
937
  versionPin?: VersionPin | null;
921
938
  sso: SsoConfig;
package/dist/org.js CHANGED
@@ -904,6 +904,20 @@ export function validateVersionPin(org) {
904
904
  return null;
905
905
  }
906
906
  export const SFU_SLOT_MAX = 20;
907
+ /**
908
+ * `null` when `signIn` is valid. The switch needs a company domain whose login host is
909
+ * `auth.<domain>`: a company on `oikos.host` (tier 1) keeps today's flow, so the declaration
910
+ * is refused there rather than silently ignored. A company with its own services is covered: its
911
+ * own sign-in stack gets the second login app.
912
+ */
913
+ export function validateSignIn(org) {
914
+ if (org.signIn?.inApp !== true)
915
+ return null;
916
+ if (!org.domain || !org.issuer.usesDomain) {
917
+ return 'signIn.inApp needs a company domain with issuer.usesDomain: a company on oikos.host keeps the sign-in on its login host';
918
+ }
919
+ return null;
920
+ }
907
921
  /** The only reader of `services.own`. Every company runs its own services, the platform's owner company too; a company without a domain runs them on oikos.host. */
908
922
  export function ownsServices(org) {
909
923
  return org.services?.own === true;
@@ -224,7 +224,10 @@ export declare function desiredAgoraEnv(org: DeclaredOrg, recorded: Readonly<Rec
224
224
  env: Record<string, string>;
225
225
  missing: string[];
226
226
  };
227
- /** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
227
+ /**
228
+ * The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored,
229
+ * except `SIGNIN_UPSTREAM`: the switch turned off must take it away, so a live one that is not desired is drift.
230
+ */
228
231
  export declare function agoraEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
229
232
  /**
230
233
  * #442: recorded once a restart has confirmed the org's live Agora process
@@ -1,5 +1,6 @@
1
1
  import { chatUpstreams } from './chat-matrix.js';
2
2
  import { FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY } from './core-files.js';
3
+ import { SIGN_IN_UPSTREAM_ENV, inAppHostApplied, signInUpstream, staleSignInUpstream, } from './sign-in.js';
3
4
  /**
4
5
  * #164: what both the planner (`diff.ts`) and the executor (`executor.ts`)
5
6
  * need to agree on for an org's Agora -- the hostname, the values the host
@@ -288,6 +289,9 @@ export function desiredAgoraEnv(org, recorded, rawHost) {
288
289
  chat: { enabled: org.chat.enabled },
289
290
  // Agora draws call buttons only when this is true.
290
291
  calls: { enabled: org.calls.enabled },
292
+ // In-app sign-in: Agora reads this at runtime (one image serves every company). Set with `SIGNIN_UPSTREAM`
293
+ // only, so Agora never shows the in-app screen before the login can serve it.
294
+ ...(inAppHostApplied(org, recorded, agoraHostnameFor(org)) ? { signInInApp: true } : {}),
291
295
  });
292
296
  }
293
297
  if (org.mail.mode !== 'off' && host.mailUpstream) {
@@ -309,6 +313,10 @@ export function desiredAgoraEnv(org, recorded, rawHost) {
309
313
  if (org.calls.enabled && callsServiceHasName(org, recorded)) {
310
314
  env.AGORA_CALLS_UPSTREAM = `http://${callsServiceInternalName(org)}:${CALLS_SERVICE_PORT}`;
311
315
  }
316
+ // In-app sign-in: the app proxies `/sign-in/*` to the login only after its host is applied there.
317
+ if (inAppHostApplied(org, recorded, agoraHostnameFor(org))) {
318
+ env[SIGN_IN_UPSTREAM_ENV] = signInUpstream(org);
319
+ }
312
320
  return { env, missing };
313
321
  }
314
322
  function stable(value) {
@@ -330,14 +338,20 @@ function sameEnvValue(key, desired, live) {
330
338
  return false;
331
339
  }
332
340
  }
333
- /** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
341
+ /**
342
+ * The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored,
343
+ * except `SIGNIN_UPSTREAM`: the switch turned off must take it away, so a live one that is not desired is drift.
344
+ */
334
345
  export function agoraEnvDrift(desired, live) {
335
- return Object.entries(desired)
346
+ const drift = Object.entries(desired)
336
347
  .filter(([key, value]) => {
337
348
  const liveValue = live[key];
338
349
  return liveValue === undefined || !sameEnvValue(key, value, liveValue);
339
350
  })
340
351
  .map(([key]) => key);
352
+ if (staleSignInUpstream(desired, live))
353
+ drift.push(SIGN_IN_UPSTREAM_ENV);
354
+ return drift;
341
355
  }
342
356
  /**
343
357
  * #442: recorded once a restart has confirmed the org's live Agora process
@@ -80,7 +80,7 @@ export declare const BOULE_BRIDGE_SECRET_ENV = "ARCHON_BRIDGE_SECRET";
80
80
  /** The role Boule creates from {@link BOULE_PEER_PASSWORD_ENV}, with grants on schema `authz` only. */
81
81
  export declare const BOULE_PEER_ROLE = "authz_operator_rt";
82
82
  /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
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"];
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", "SIGNIN_UPSTREAM"];
84
84
  /**
85
85
  * The env vars an org's Boule app should carry, and every input still missing
86
86
  * to build them. Callers that write must refuse on a non-empty `missing`: a
@@ -124,5 +124,8 @@ export declare function wantedBouleBrokerEntry(org: DeclaredOrg, recorded: Reado
124
124
  } | {
125
125
  missing: BrokerEntryInput[];
126
126
  };
127
- /** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
127
+ /**
128
+ * The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored,
129
+ * except `SIGNIN_UPSTREAM`: the switch turned off must take it away, so a live one that is not desired is drift.
130
+ */
128
131
  export declare function bouleEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_NO_REPLY_LOCAL_PART } from '../org.js';
2
2
  import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
3
+ import { SIGN_IN_UPSTREAM_ENV, inAppHostApplied, signInUpstream, staleSignInUpstream, } from './sign-in.js';
3
4
  /**
4
5
  * #161 (Boule part): what both the planner (`diff.ts`) and the executor
5
6
  * (`executor.ts`) need to agree on for an org's Boule -- the hostname, the
@@ -103,6 +104,7 @@ export const BOULE_MANAGED_ENV_KEYS = [
103
104
  BOULE_MAIL_TOKEN_ENV,
104
105
  BOULE_PEER_PASSWORD_ENV,
105
106
  BOULE_BRIDGE_SECRET_ENV,
107
+ SIGN_IN_UPSTREAM_ENV,
106
108
  ];
107
109
  /**
108
110
  * The env vars an org's Boule app should carry, and every input still missing
@@ -162,6 +164,10 @@ export function desiredBouleEnv(org, recorded, issuer, archonUrl, mailWanted = f
162
164
  const mailDomain = recorded[MAIL_DOMAIN_RECORDED_KEY];
163
165
  if (mailWanted && mailDomain)
164
166
  env.MAIL_FROM = bouleMailFrom(org, mailDomain);
167
+ // In-app sign-in: the app proxies `/sign-in/*` to the login only after its host is applied there.
168
+ if (inAppHostApplied(org, recorded, bouleHostnameFor(org))) {
169
+ env[SIGN_IN_UPSTREAM_ENV] = signInUpstream(org);
170
+ }
165
171
  return { env, missing };
166
172
  }
167
173
  /** The `AUTH_APPS` name of an org's Boule broker entry, and the scope key suffix it records under. */
@@ -201,9 +207,15 @@ export function wantedBouleBrokerEntry(org, recorded) {
201
207
  },
202
208
  };
203
209
  }
204
- /** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
210
+ /**
211
+ * The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored,
212
+ * except `SIGNIN_UPSTREAM`: the switch turned off must take it away, so a live one that is not desired is drift.
213
+ */
205
214
  export function bouleEnvDrift(desired, live) {
206
- return Object.entries(desired)
215
+ const drift = Object.entries(desired)
207
216
  .filter(([key, value]) => live[key] !== value)
208
217
  .map(([key]) => key);
218
+ if (staleSignInUpstream(desired, live))
219
+ drift.push(SIGN_IN_UPSTREAM_ENV);
220
+ return drift;
209
221
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Foundry deploys from its own pipeline, so Coolify's pull-request previews
3
+ * stay off on every app it manages (#788).
4
+ *
5
+ * Whether a live app has them on. `@wtfalch/providers` types the field on
6
+ * create and update only, and the read location (top level, or under
7
+ * `settings` as `is_consistent_container_name_enabled` is) is not documented,
8
+ * so both are read. Absent in both reads as off.
9
+ */
10
+ export declare function previewsOn(app: unknown): boolean;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Foundry deploys from its own pipeline, so Coolify's pull-request previews
3
+ * stay off on every app it manages (#788).
4
+ *
5
+ * Whether a live app has them on. `@wtfalch/providers` types the field on
6
+ * create and update only, and the read location (top level, or under
7
+ * `settings` as `is_consistent_container_name_enabled` is) is not documented,
8
+ * so both are read. Absent in both reads as off.
9
+ */
10
+ export function previewsOn(app) {
11
+ const a = app;
12
+ return (a?.is_preview_deployments_enabled === true ||
13
+ a?.settings?.is_preview_deployments_enabled === true);
14
+ }
package/dist/plan/diff.js CHANGED
@@ -12,7 +12,7 @@ import { planFilesKeys } from './entry-files-key.js';
12
12
  import { hasWildcardGate, planGates } from './gates.js';
13
13
  import { appHostname, tunnelDnsHostnames } from './hostnames.js';
14
14
  import { lockedHostnames } from './locked-hostnames.js';
15
- import { LOGIN_BINDING_SCOPE_KEY, agoraIssuerHostFor, companyLoginTunnelHost, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, pendingRemovalHosts, } from './login-host.js';
15
+ import { LOGIN_BINDING_SCOPE_KEY, agoraIssuerHostFor, companyLoginTunnelHost, inAppApps, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, pendingRemovalHosts, } from './login-host.js';
16
16
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
17
17
  import { mailStoreSwitchChange } from './mail-store.js';
18
18
  import { hasAdoptedResources, networkMoveNeeded, planNetworkMove } from './network-move.js';
@@ -24,6 +24,7 @@ import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
24
24
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
25
25
  import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryRoutes, entryRoutesMarker, } from './proxy-labels.js';
26
26
  import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, signInDomainBlockedText, signInOrgDomain, } from './sign-in-org.js';
27
+ import { SIGN_IN_BASE_PATH, SIGN_IN_IMAGE_TAG_SUFFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_LOGIN_INTERNAL_NAME, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, inAppSignIn, inAppStatePairs, inAppTargets, } from './sign-in.js';
27
28
  import { withStepSystem } from './step-system.js';
28
29
  import { planWorkers } from './worker.js';
29
30
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
@@ -228,7 +229,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
228
229
  // #267: after the ZITADEL org step, before any app's own issuer
229
230
  // application. Apps created later in this run are not in the binding yet;
230
231
  // the next run replans this step and repoints them.
231
- const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false);
232
+ const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false, platformReadiness.signInLoginReady ?? false);
232
233
  if (loginHost.step)
233
234
  plannedSteps.push(loginHost.step);
234
235
  // #444a: the org's one shared Postgres server and its fixed schema
@@ -1085,13 +1086,21 @@ export function isPlatformOwnOrg(org, platformOwnerOrgId) {
1085
1086
  * platform's own domain, never plans the shared ZITADEL instance's own login
1086
1087
  * host as its own -- `auth.companyincluded.com` colliding with itself.
1087
1088
  */
1088
- function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired) {
1089
+ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired, signInLoginReady = false) {
1089
1090
  const none = { step: null, blocked: null };
1090
1091
  // An own org's instance has no shared login host to bind: its issuer is the sign-in service's own.
1092
+ // Only in-app sign-in plans a step for it: the app hosts, through its own in-app login app.
1091
1093
  if (ownsServices(org))
1092
- return none;
1094
+ return planOwnLoginHost(org, recordedIds);
1093
1095
  const key = resourceKey('zitadel', 'login-host', org.id);
1094
1096
  const host = loginHostnameFor(org);
1097
+ // In-app sign-in: each app host gets its own binding, trusted domain and base URI. Until the second
1098
+ // login app that serves `/sign-in` is applied, no app host is planned (a blocked line says so).
1099
+ const wantedApps = inAppApps(org, recordedIds);
1100
+ const apps = signInLoginReady ? wantedApps : [];
1101
+ const inAppWaiting = wantedApps.length > 0 && !signInLoginReady
1102
+ ? `${org.slug}'s in-app sign-in waits for the login app that serves ${SIGN_IN_BASE_PATH} (${SIGN_IN_LOGIN_INTERNAL_NAME}), which the platform has not applied`
1103
+ : null;
1095
1104
  function stepFor() {
1096
1105
  return {
1097
1106
  step: {
@@ -1099,7 +1108,11 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
1099
1108
  system: 'zitadel',
1100
1109
  kind: 'login-host',
1101
1110
  key: org.id,
1102
- desired: { host },
1111
+ desired: apps.length > 0 ||
1112
+ recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] ||
1113
+ recordedIds[SIGN_IN_TRUSTED_SCOPE_KEY]
1114
+ ? { host, apps }
1115
+ : { host },
1103
1116
  },
1104
1117
  step: {
1105
1118
  description: `set ${org.slug}'s login host to ${host}`,
@@ -1107,7 +1120,7 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
1107
1120
  migrates: false,
1108
1121
  },
1109
1122
  },
1110
- blocked: null,
1123
+ blocked: inAppWaiting,
1111
1124
  };
1112
1125
  }
1113
1126
  // Already applied -- a re-run plans nothing unless the host changed, an app
@@ -1116,11 +1129,14 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
1116
1129
  // `zitadelLoginHost` always advances the recorded host to the current one,
1117
1130
  // so a pending removal is the only way to know one is still owed, and
1118
1131
  // nothing else here would replan the step to retry it.
1132
+ const targets = inAppTargets(apps, recordedIds);
1119
1133
  if (recordedIds[key] === host &&
1120
1134
  recordedIds[LOGIN_BINDING_SCOPE_KEY] ===
1121
- loginBindingState(host, loginHostClientIds(org, recordedIds)) &&
1135
+ loginBindingState(host, loginHostClientIds(org, recordedIds, inAppStatePairs(targets.active).map((pair) => pair.clientId))) &&
1136
+ (recordedIds[SIGN_IN_TRUSTED_SCOPE_KEY] ?? '') === targets.trusted &&
1137
+ (recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] ?? '') === targets.active &&
1122
1138
  pendingRemovalHosts(recordedIds).length === 0) {
1123
- return none;
1139
+ return { step: null, blocked: inAppWaiting };
1124
1140
  }
1125
1141
  // #177: nothing to add a trusted domain to yet -- `planIssuer` already
1126
1142
  // reports the platform's sign-in as blocked, so this step stays silent
@@ -1132,6 +1148,55 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
1132
1148
  // repoint nothing until the port that writes the binding is wired.
1133
1149
  return loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned) ?? stepFor();
1134
1150
  }
1151
+ /**
1152
+ * In-app sign-in for a company that runs its own services: each app host is trusted in the company's
1153
+ * own ZITADEL, bound in its in-app login app (`<slug>-auth-login-sign-in`) and given
1154
+ * `https://<app host>/sign-in/` as its Login V2 base URI. Planned once the company's own sign-in is
1155
+ * applied and that login app is recorded; until then one blocked line says what it waits for.
1156
+ * Archon's host is not here: the platform's Archon step owns it.
1157
+ */
1158
+ function planOwnLoginHost(org, recordedIds) {
1159
+ const none = { step: null, blocked: null };
1160
+ // Nothing wanted and nothing owed.
1161
+ if (!inAppSignIn(org) && !recordedIds[SIGN_IN_IN_APP_SCOPE_KEY])
1162
+ return none;
1163
+ // The company's own sign-in is reported on its own line (`coolify:own-auth`).
1164
+ if (!ownAuthApplied(org, recordedIds))
1165
+ return none;
1166
+ const host = ownHostnames(org).auth;
1167
+ const wantedApps = inAppApps(org, recordedIds);
1168
+ const ready = Boolean(recordedIds[SIGN_IN_LOGIN_APP_SCOPE_KEY]);
1169
+ const apps = ready ? wantedApps : [];
1170
+ const waiting = wantedApps.length > 0 && !ready
1171
+ ? `${org.slug}'s in-app sign-in waits for the login app that serves ${SIGN_IN_BASE_PATH} (${org.slug}-auth-login${SIGN_IN_IMAGE_TAG_SUFFIX}), which the company's own sign-in has not applied`
1172
+ : null;
1173
+ // The company's own login host needs nothing here, so only the app hosts decide whether work is owed.
1174
+ const targets = inAppTargets(apps, recordedIds);
1175
+ if ((recordedIds[SIGN_IN_TRUSTED_SCOPE_KEY] ?? '') === targets.trusted &&
1176
+ (recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] ?? '') === targets.active &&
1177
+ pendingRemovalHosts(recordedIds).length === 0) {
1178
+ return { step: null, blocked: waiting };
1179
+ }
1180
+ // Nothing to trust a domain in, or to bind in, before the org exists in the company's ZITADEL.
1181
+ if (!recordedIds.zitadel_org)
1182
+ return { step: null, blocked: waiting };
1183
+ return {
1184
+ step: {
1185
+ action: {
1186
+ system: 'zitadel',
1187
+ kind: 'login-host',
1188
+ key: org.id,
1189
+ desired: { host, apps },
1190
+ },
1191
+ step: {
1192
+ description: `set ${org.slug}'s app hosts to sign in on their own addresses`,
1193
+ destroys: false,
1194
+ migrates: false,
1195
+ },
1196
+ },
1197
+ blocked: waiting,
1198
+ };
1199
+ }
1135
1200
  /**
1136
1201
  * Every sign-in app this org already has live (web or native, not api) gets
1137
1202
  * "user info inside the ID token" switched on once: apps made before the
@@ -2905,6 +2970,21 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2905
2970
  blocked,
2906
2971
  };
2907
2972
  }
2973
+ // #788: pull-request previews on a live app. `coolifyApp` patches them off
2974
+ // on every run, so replanning this one step is the whole repair.
2975
+ if (live.config.previewsEnabled === true) {
2976
+ return {
2977
+ step: {
2978
+ action,
2979
+ step: {
2980
+ description: `turn Coolify's preview deployments off for ${org.slug}'s ${label} (#788)`,
2981
+ destroys: false,
2982
+ migrates: false,
2983
+ },
2984
+ },
2985
+ blocked,
2986
+ };
2987
+ }
2908
2988
  // #239 (rule 4), from live state: an app with a database but no
2909
2989
  // `DATABASE_URL_OWNER` env still has the pre-split superuser URL, or never
2910
2990
  // got the push. Names only, never values. A reader that did not list env
@@ -3738,8 +3818,11 @@ function accessRuleDescription(org, rule) {
3738
3818
  function planRateLimit(org, recordedIds) {
3739
3819
  if (!(org.hosting.usesDomain || org.site.enabled) || !org.domain)
3740
3820
  return null;
3741
- if (recordedIds[resourceKey('cloudflare', 'ratelimit', org.id)])
3821
+ // Recorded, and the rule is the variant the switch asks for (the in-app one also covers POSTs under `/sign-in/`).
3822
+ if (recordedIds[resourceKey('cloudflare', 'ratelimit', org.id)] &&
3823
+ (recordedIds[SIGN_IN_RATE_LIMIT_SCOPE_KEY] === '1') === inAppSignIn(org)) {
3742
3824
  return null;
3825
+ }
3743
3826
  return {
3744
3827
  action: { system: 'cloudflare', kind: 'ratelimit', key: org.id, desired: {} },
3745
3828
  step: {
@@ -29,6 +29,11 @@ export interface RateLimitPort {
29
29
  createRateLimitRule(zoneId: string, rule: RateLimitRuleInput): Promise<{
30
30
  id: string;
31
31
  }>;
32
+ /**
33
+ * Deletes that one rule. Absent on a port that cannot; a step that needs it then fails by name.
34
+ * Rejects with a not-found error when the rule is already gone.
35
+ */
36
+ deleteRateLimitRule?(zoneId: string, id: string): Promise<void>;
32
37
  }
33
38
  /** Edge answers a create another create of the same rule is making `conflict` + `busy`. */
34
39
  export declare const EDGE_RATELIMITS_BUSY_ATTEMPTS = 5;
@@ -45,14 +45,16 @@ export function createEdgeRateLimits(client, options = {}) {
45
45
  return;
46
46
  const { rules } = await retrying(() => client.list({ zoneId, description }));
47
47
  const view = rules.find((r) => r.description === description && r.state !== 'removed');
48
- if (view && view.owner === null) {
49
- const result = await writeSlot(() => retrying(() => client.adopt({ rules: [{ zoneId, description, id: view.id }] })));
50
- if (result.skipped.length > 0) {
51
- throw new Error(`edge rate limits: rule '${description}' in zone ${zoneId} cannot be adopted (${result.skipped[0]?.reason}); remove or fix it by hand`);
52
- }
53
- }
48
+ if (view && view.owner === null)
49
+ await adoptView(zoneId, view);
54
50
  adopted.add(key);
55
51
  };
52
+ const adoptView = async (zoneId, view) => {
53
+ const result = await writeSlot(() => retrying(() => client.adopt({ rules: [{ zoneId, description: view.description ?? '', id: view.id }] })));
54
+ if (result.skipped.length > 0) {
55
+ throw new Error(`edge rate limits: rule '${view.description}' in zone ${zoneId} cannot be adopted (${result.skipped[0]?.reason}); remove or fix it by hand`);
56
+ }
57
+ };
56
58
  return {
57
59
  async listRateLimitRules(zoneId) {
58
60
  const { rules } = await retrying(() => client.list({ zoneId }));
@@ -60,6 +62,14 @@ export function createEdgeRateLimits(client, options = {}) {
60
62
  .filter((r) => r.state !== 'removed')
61
63
  .map((r) => ({ id: r.id, description: r.description }));
62
64
  },
65
+ async deleteRateLimitRule(zoneId, id) {
66
+ // Edge removes only a rule it owns: a live rule it has no row for is adopted first, as a create does.
67
+ const { rules } = await retrying(() => client.list({ zoneId }));
68
+ const view = rules.find((r) => r.id === id && r.state !== 'removed');
69
+ if (view && view.owner === null)
70
+ await adoptView(zoneId, view);
71
+ await writeSlot(() => retrying(() => client.remove(zoneId, id)));
72
+ },
63
73
  async createRateLimitRule(zoneId, rule) {
64
74
  await adoptLive(zoneId, rule.description);
65
75
  const create = () => writeSlot(() => retrying(() => client.create({ zoneId, ...rule })));
@@ -107,7 +107,10 @@ export interface CoolifyPort {
107
107
  custom_labels?: string | null;
108
108
  settings?: {
109
109
  is_consistent_container_name_enabled?: boolean;
110
+ is_preview_deployments_enabled?: boolean;
110
111
  };
112
+ /** #788: where Coolify reports previews is not documented, so `previewsOn` reads both places. */
113
+ is_preview_deployments_enabled?: boolean;
111
114
  custom_network_aliases?: string | null;
112
115
  health_check_enabled?: boolean;
113
116
  ports_exposes?: string;
@@ -120,6 +123,8 @@ export interface CoolifyPort {
120
123
  custom_docker_run_options?: string | null;
121
124
  }>;
122
125
  createGithubAppApplication(input: {
126
+ /** #788: previews off; every git app create sends `false`. */
127
+ is_preview_deployments_enabled?: boolean;
123
128
  project_uuid: string;
124
129
  server_uuid: string;
125
130
  /** The Docker network the app joins (`plan/destinations.ts`); absent: the server's default. */
@@ -1140,6 +1145,14 @@ export interface ZitadelPort {
1140
1145
  * the binding holds; `zitadelApp` never sets it at creation.
1141
1146
  */
1142
1147
  updateOidcAppLoginBaseUri(orgId: string, projectId: string, appId: string, loginBaseUri: string): Promise<void>;
1148
+ /**
1149
+ * Lets each app's own Login V2 base URI count: foundry starts ZITADEL with `LOGINV2_REQUIRED=true`,
1150
+ * which makes the instance ignore it. The host sets the instance feature (`PUT /v2/features/instance`
1151
+ * with `loginV2.required` false and the instance default as `baseUri`). Called before an app is
1152
+ * pointed at its own `/sign-in/` on a company that runs its own ZITADEL; the shared instance's
1153
+ * bindings port does the same for the platform's. Optional: absent, nothing is called.
1154
+ */
1155
+ allowPerAppLoginBaseUri?(instanceBaseUri: string): Promise<void>;
1143
1156
  /**
1144
1157
  * Occupies `userName` in `orgId` with a machine user that has no role and
1145
1158
  * no token, so nobody can register under that name. Idempotent: a user
@@ -2006,6 +2019,13 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2006
2019
  * client id that binding holds. Apps created later are not in it yet:
2007
2020
  * the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
2008
2021
  *
2022
+ * In-app sign-in (`signIn.inApp`, `plan/sign-in.ts` has the order): an app host is trusted first. It is
2023
+ * bound, and its app pointed at `/sign-in/`, only once its app runs `SIGNIN_UPSTREAM` (the app step
2024
+ * records that). The bind moves the app's client id off the company's binding in the same write (the
2025
+ * login refuses a client id in two bindings); a company binding left with no client is removed.
2026
+ * Switched off, the client id goes back and the base URI returns to the login host, and a trusted
2027
+ * domain is given up only after the app dropped the env.
2028
+ *
2009
2029
  * #274: when the org's host has moved (`recorded[key]`, the *previous*
2010
2030
  * run's resourceId, differs from this run's `host`), also removes every
2011
2031
  * host that is no longer the org's current one from both places --
@@ -2067,6 +2087,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2067
2087
  * the snapshot taken before the repoint still named it.
2068
2088
  */
2069
2089
  private zitadelLoginHost;
2090
+ /**
2091
+ * The host table of a company's own in-app login app as a bindings port that collects its edits and
2092
+ * writes them once on `flush` (one redeploy, none when nothing changed). Used for a company that runs
2093
+ * its own services, which has no host-supplied bindings port.
2094
+ */
2095
+ private ownSignInBindings;
2070
2096
  /**
2071
2097
  * `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
2072
2098
  * in through a second ZITADEL org, `<slug> customers`, with
@@ -2825,6 +2851,18 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2825
2851
  * behind it.
2826
2852
  */
2827
2853
  private coolifyAgora;
2854
+ /**
2855
+ * Records whether an app's running env holds `SIGNIN_UPSTREAM` (the app's host while it does, `''` once it
2856
+ * does not), so the login-host step knows when to point the base URI at the app and when it may give the
2857
+ * host up. Written only when the value changes.
2858
+ */
2859
+ private recordSignInEnvLive;
2860
+ /**
2861
+ * In-app sign-in switched off (or its host given up): the app's live `SIGNIN_UPSTREAM` is deleted, because
2862
+ * `bulkUpsertEnvs` never removes a key. Left in place, the app would keep proxying `/sign-in/*` to the login.
2863
+ * Nothing happens while the env is desired or the app does not hold it.
2864
+ */
2865
+ private dropStaleSignInUpstream;
2828
2866
  /**
2829
2867
  * `plan/diff.ts`'s `planBoule`: provisions the org's own Boule
2830
2868
  * (https://github.com/wtfalch/boule) as one more Coolify application, from
@@ -3433,3 +3471,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
3433
3471
  * `:`. It never scans the path or the response body.
3434
3472
  */
3435
3473
  export declare function isNotFoundError(error: unknown): boolean;
3474
+ /** True when ZITADEL refused an update because the value already is the wanted one. Matches the error code, never the free text. */
3475
+ export declare function isNoChangesError(error: unknown): boolean;
3476
+ /**
3477
+ * {@link ZitadelPort.updateOidcAppLoginBaseUri} that counts an app already holding the wanted base URI as
3478
+ * done: ZITADEL answers that write with "No changes" and a repeat apply must not fail on it.
3479
+ */
3480
+ export declare function setOidcAppLoginBaseUri(zitadel: Pick<ZitadelPort, 'updateOidcAppLoginBaseUri'>, orgId: string, projectId: string, appId: string, loginBaseUri: string): Promise<void>;