@wtfalch/foundry 0.35.2 → 0.36.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.
@@ -34,10 +34,16 @@ export declare const DIGESTS: PinnedVersions;
34
34
  */
35
35
  export declare function pinnedRef(key: keyof PinnedVersions): string;
36
36
  /**
37
- * A declared tag that equals the pinned one gets its digest; any other
38
- * declared tag (an override) is passed through unchanged.
37
+ * The two image fields Coolify accepts. Its tag rule has no `@` or `:`, so a
38
+ * digest goes in the name as `<image>@sha256` and the tag holds the bare
39
+ * 64-hex digest (Coolify pulls `<name>:<tag>`). A declared tag that equals the
40
+ * pinned one gets its digest; any other declared tag (an override) is passed
41
+ * through unchanged.
39
42
  */
40
- export declare function pinnedTagFor(key: keyof PinnedVersions, tag: string): string;
43
+ export declare function coolifyImageFor(key: keyof PinnedVersions, image: string, tag: string): {
44
+ docker_registry_image_name: string;
45
+ docker_registry_image_tag: string;
46
+ };
41
47
  /**
42
48
  * Why an image tag cannot be used, or `null`. #248: generalised out of what
43
49
  * was `platform-providers.ts`'s `mailImageTagBlocked`, the one guard
@@ -41,11 +41,20 @@ export function pinnedRef(key) {
41
41
  return `${VERSIONS[key]}@${DIGESTS[key]}`;
42
42
  }
43
43
  /**
44
- * A declared tag that equals the pinned one gets its digest; any other
45
- * declared tag (an override) is passed through unchanged.
44
+ * The two image fields Coolify accepts. Its tag rule has no `@` or `:`, so a
45
+ * digest goes in the name as `<image>@sha256` and the tag holds the bare
46
+ * 64-hex digest (Coolify pulls `<name>:<tag>`). A declared tag that equals the
47
+ * pinned one gets its digest; any other declared tag (an override) is passed
48
+ * through unchanged.
46
49
  */
47
- export function pinnedTagFor(key, tag) {
48
- return tag === VERSIONS[key] ? pinnedRef(key) : tag;
50
+ export function coolifyImageFor(key, image, tag) {
51
+ if (tag !== VERSIONS[key]) {
52
+ return { docker_registry_image_name: image, docker_registry_image_tag: tag };
53
+ }
54
+ return {
55
+ docker_registry_image_name: `${image}@sha256`,
56
+ docker_registry_image_tag: DIGESTS[key].replace(/^sha256:/, ''),
57
+ };
49
58
  }
50
59
  /**
51
60
  * Tag spellings that resolve to whatever the registry currently has, not a
package/dist/plan/diff.js CHANGED
@@ -899,7 +899,7 @@ function planOrgRegistrationClosed(org, recordedIds, authReady, issuerStep) {
899
899
  },
900
900
  };
901
901
  }
902
- /** The blocked item for an org sign-in domain the sign-in server refused to make primary. */
902
+ /** The note for an org sign-in domain the sign-in server refused to make primary, while it is not registered. */
903
903
  function orgDomainBlocked(org, recordedIds) {
904
904
  const domain = signInOrgDomain(org);
905
905
  if (recordedIds[ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY]?.toLowerCase() !== domain)
@@ -925,9 +925,7 @@ function planOrgDomain(org, recordedIds, authReady, issuerStep) {
925
925
  const domain = signInOrgDomain(org);
926
926
  if (recordedIds[ZITADEL_ORG_DOMAIN_SCOPE_KEY]?.toLowerCase() === domain)
927
927
  return null;
928
- // Refused once: reported as blocked (see `orgDomainBlocked`), not planned again.
929
- if (recordedIds[ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY]?.toLowerCase() === domain)
930
- return null;
928
+ // A refused domain is planned again (and reported, see `orgDomainBlocked`): the step sets the policy it needs.
931
929
  return {
932
930
  action: { system: 'zitadel', kind: 'domain', key: org.id, desired: { domain } },
933
931
  step: {
@@ -936,6 +936,15 @@ export interface ZitadelPort {
936
936
  addOrgDomain(orgId: string, domain: string): Promise<void>;
937
937
  /** Makes a domain already on the org its primary one. Idempotent: no-op when it already is. */
938
938
  setPrimaryOrgDomain(orgId: string, domain: string): Promise<void>;
939
+ /** Removes a domain from the org. The server refuses the primary one. */
940
+ removeOrgDomain(orgId: string, domain: string): Promise<void>;
941
+ /** Sets the instance's domain policy. Writes only when a field differs. */
942
+ ensureDomainPolicy(want: {
943
+ userLoginMustBeDomain: boolean;
944
+ validateOrgDomains: boolean;
945
+ }): Promise<{
946
+ changed: boolean;
947
+ }>;
939
948
  setUserMetadata(orgId: string, userId: string, key: string, value: string): Promise<void>;
940
949
  getUserMetadata(orgId: string, userId: string, key: string): Promise<string | null>;
941
950
  findUserByEmail(orgId: string, email: string): Promise<{
@@ -1,7 +1,7 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { randomBytes, randomUUID } from 'node:crypto';
3
3
  import { URL } from 'node:url';
4
- import { pinnedTagFor } from '../bootstrap/versions.js';
4
+ import { coolifyImageFor } from '../bootstrap/versions.js';
5
5
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
6
  import { resolveCallsSfuUrls } from '../calls-sfu.js';
7
7
  import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
@@ -3870,8 +3870,7 @@ export class FoundryStepExecutor {
3870
3870
  server_uuid: server.uuid,
3871
3871
  environment_name: environment.name,
3872
3872
  environment_uuid: environment.uuid,
3873
- docker_registry_image_name: SYNAPSE_IMAGE,
3874
- docker_registry_image_tag: pinnedTagFor('synapse', synapseTag),
3873
+ ...coolifyImageFor('synapse', SYNAPSE_IMAGE, synapseTag),
3875
3874
  ports_exposes: '8008',
3876
3875
  name: names.synapse,
3877
3876
  description: `Synapse for ${org.slug}'s calls stack (${SYNAPSE_IMAGE}:${synapseTag})`,
@@ -1,4 +1,4 @@
1
- import { pinnedTagFor } from '../bootstrap/versions.js';
1
+ import { coolifyImageFor } from '../bootstrap/versions.js';
2
2
  import { mintLivekitCredentials } from '../calls-sfu-keys.js';
3
3
  import { LIVEKIT_SIGNALLING_PORT, sfuSlotPorts } from '../calls-sfu.js';
4
4
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
@@ -105,8 +105,7 @@ export async function provisionSfuApp(scope, deps, target, input) {
105
105
  server_uuid: server.uuid,
106
106
  environment_name: environment.name,
107
107
  environment_uuid: environment.uuid,
108
- docker_registry_image_name: OWN_SFU_IMAGE,
109
- docker_registry_image_tag: pinnedTagFor('livekit', input.version),
108
+ ...coolifyImageFor('livekit', OWN_SFU_IMAGE, input.version),
110
109
  ports_exposes: String(LIVEKIT_SIGNALLING_PORT),
111
110
  ports_mappings: sfuPortsMappings(input.slot),
112
111
  name: target.names.sfuApp,
@@ -122,7 +121,7 @@ export async function provisionSfuApp(scope, deps, target, input) {
122
121
  // Set on every run: a declared version or slot reaches an existing app here.
123
122
  await coolify.updateApplication(appId, {
124
123
  custom_internal_name: target.internal.sfu,
125
- docker_registry_image_tag: pinnedTagFor('livekit', input.version),
124
+ ...coolifyImageFor('livekit', OWN_SFU_IMAGE, input.version),
126
125
  ports_exposes: String(LIVEKIT_SIGNALLING_PORT),
127
126
  ports_mappings: sfuPortsMappings(input.slot),
128
127
  ...healthCheckSettings('/', LIVEKIT_SIGNALLING_PORT),
@@ -9,7 +9,7 @@ import { legacyAppHostname } from './hostnames.js';
9
9
  import { PLATFORM_MAIL_RELAY_KEY, planMailRelay } from './mail-relay.js';
10
10
  import { planOikosIngress } from './oikos-ingress.js';
11
11
  import { callsSfuAppliedFingerprint } from './platform-sfu.js';
12
- import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, signInDomainBlockedText } from './sign-in-org.js';
12
+ import { signInDomainBlockedText } from './sign-in-org.js';
13
13
  import { withStepSystem } from './step-system.js';
14
14
  /**
15
15
  * The `Plan` (#19's public contract) a {@link PlatformPlanDetail} carries,
@@ -2905,8 +2905,8 @@ export const ARCHON_ORG_BLOCKED = 'Archon is blocked, not planned: its own sign-
2905
2905
  * {@link PLATFORM_ARCHON_KEY}, planned while any of those is not recorded
2906
2906
  * against the recorded org, and always before `platform:archon`. Blocked, not
2907
2907
  * planned, while the host cannot reach the instance. A refused domain is
2908
- * reported as a standing note (as the owner org's is) and does not hold the
2909
- * step; an invite that cannot be sent (no work address, no mail port) is a
2908
+ * reported as a standing note (as the owner org's is) and planned again, since
2909
+ * the step sets the domain policy it needs; an invite that cannot be sent (no work address, no mail port) is a
2910
2910
  * standing note and does not hold it either.
2911
2911
  */
2912
2912
  function planArchonOrg(platform, recordedIds, executorBlocked, inviteBlocked, ownerSignIn) {
@@ -2914,7 +2914,8 @@ function planArchonOrg(platform, recordedIds, executorBlocked, inviteBlocked, ow
2914
2914
  return { step: null, blocked: null };
2915
2915
  const orgId = recordedIds[PLATFORM_ARCHON_ORG_SCOPE_KEY];
2916
2916
  const blockedDomain = recordedIds[PLATFORM_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY];
2917
- const domainDone = Boolean(recordedIds[PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY]) || Boolean(blockedDomain);
2917
+ // A refused domain is planned again: the step sets the domain policy it needs.
2918
+ const domainDone = Boolean(recordedIds[PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY]);
2918
2919
  const inviteRecorded = recordedIds[PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY];
2919
2920
  const inviteDone = Boolean(inviteBlocked) ||
2920
2921
  inviteRecorded === 'none' ||
@@ -2922,7 +2923,7 @@ function planArchonOrg(platform, recordedIds, executorBlocked, inviteBlocked, ow
2922
2923
  const standing = [
2923
2924
  inviteBlocked,
2924
2925
  blockedDomain && !recordedIds[PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY]
2925
- ? `Archon's sign-in domain ${blockedDomain} is blocked, not planned: the sign-in server refused to make it primary because its domain policy wants proof of ownership. Set the policy ValidateOrgDomains to false on the sign-in server, then remove the recorded key ${PLATFORM_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY}`
2926
+ ? signInDomainBlockedText('Archon', blockedDomain)
2926
2927
  : null,
2927
2928
  ].filter((v) => Boolean(v));
2928
2929
  const standingNote = standing.length > 0 ? standing.join('; ') : null;
@@ -3042,5 +3043,5 @@ function ownerDomainBlockedText(recordedIds) {
3042
3043
  return null;
3043
3044
  if (recordedIds[PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY] === blockedDomain)
3044
3045
  return null;
3045
- return signInDomainBlockedText('the platform owner', blockedDomain).replace(ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY);
3046
+ return signInDomainBlockedText('the platform owner', blockedDomain);
3046
3047
  }
@@ -112,7 +112,7 @@ export interface PlatformAuthDeps {
112
112
  tunnels: PlatformTunnelsFactory;
113
113
  cloudflare: Pick<CloudflarePort, 'findZoneByApex'>;
114
114
  /** Must target the platform's own new instance (its issuer), or `zitadelForInstance` must be set. */
115
- zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName' | 'closeSelfRegistration' | 'enforceSecondFactor' | 'inviteOrgOwner' | 'addOrgDomain' | 'setPrimaryOrgDomain' | 'setUserMetadata' | 'getUserMetadata'> & PlatformZitadelSmtp;
115
+ zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName' | 'closeSelfRegistration' | 'enforceSecondFactor' | 'inviteOrgOwner' | 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain' | 'setUserMetadata' | 'getUserMetadata'> & PlatformZitadelSmtp;
116
116
  /**
117
117
  * Mails the owner's first-sign-in link to their personal address (the same
118
118
  * port as `FoundryStepExecutorPorts.ownerInviteMail`). The link carries the
@@ -1,7 +1,7 @@
1
1
  import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
2
2
  import { isIPv4 } from 'node:net';
3
3
  import { applyObjects, openInstance, platformMailSettingsObjects } from '@wtfalch/email/postmaster';
4
- import { VERSIONS, floatingTagBlocked, pinnedRef, pinnedTagFor } from '../bootstrap/versions.js';
4
+ import { VERSIONS, coolifyImageFor, floatingTagBlocked, pinnedRef } from '../bootstrap/versions.js';
5
5
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
6
  import { ownerMailboxes } from '../org.js';
7
7
  import { BOULE_BRIDGE_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_PEER_ROLE, bouleHostnameFor, } from './boule.js';
@@ -2539,8 +2539,7 @@ export async function provisionMailServer(scope, deps, target, input) {
2539
2539
  server_uuid: server.uuid,
2540
2540
  environment_name: environment.name,
2541
2541
  environment_uuid: environment.uuid,
2542
- docker_registry_image_name: STALWART_IMAGE,
2543
- docker_registry_image_tag: pinnedTagFor('stalwart', imageTag),
2542
+ ...coolifyImageFor('stalwart', STALWART_IMAGE, imageTag),
2544
2543
  ports_exposes: STALWART_PORTS_EXPOSES,
2545
2544
  ...(hostPorts ? { ports_mappings: hostPorts } : {}),
2546
2545
  name: target.names.mailApp,
@@ -2560,7 +2559,7 @@ export async function provisionMailServer(scope, deps, target, input) {
2560
2559
  // `'ready'` probe below. Stalwart's own readiness route and HTTP port.
2561
2560
  await deps.coolify.updateApplication(appId, {
2562
2561
  custom_internal_name: target.internal.mail,
2563
- docker_registry_image_tag: pinnedTagFor('stalwart', imageTag),
2562
+ ...coolifyImageFor('stalwart', STALWART_IMAGE, imageTag),
2564
2563
  ...healthCheckSettings(STALWART_READY_PATH, Number(STALWART_HTTP_PORT)),
2565
2564
  // Only a platform that uses the shared relay, or one that did, changes a published port.
2566
2565
  ...(portsChanged ? { ports_mappings: hostPorts } : {}),
@@ -7263,8 +7262,7 @@ async function provisionPlatformHc(context, deps) {
7263
7262
  server_uuid: server.uuid,
7264
7263
  environment_name: environment.name,
7265
7264
  environment_uuid: environment.uuid,
7266
- docker_registry_image_name: HC_IMAGE,
7267
- docker_registry_image_tag: pinnedTagFor('hc', gitRef),
7265
+ ...coolifyImageFor('hc', HC_IMAGE, gitRef),
7268
7266
  ports_exposes: HC_PORT,
7269
7267
  name: PLATFORM_HC_APP_NAME,
7270
7268
  description: `Healthchecks for the platform's own heartbeats (#540, ${HC_IMAGE}:${gitRef})`,
@@ -7277,7 +7275,7 @@ async function provisionPlatformHc(context, deps) {
7277
7275
  // own rule. The image tag too, so a declared release change reaches the app.
7278
7276
  await deps.coolify.updateApplication(appId, {
7279
7277
  custom_internal_name: HC_INTERNAL_NAME,
7280
- docker_registry_image_tag: pinnedTagFor('hc', gitRef),
7278
+ ...coolifyImageFor('hc', HC_IMAGE, gitRef),
7281
7279
  ...healthCheckSettings(HC_HEALTH_CHECK_PATH, Number(HC_PORT)),
7282
7280
  });
7283
7281
  // ---- env, parsed off the database's own owner credentials -----------------
@@ -9,30 +9,41 @@ export declare const ZITADEL_ORG_DOMAIN_SCOPE_KEY = "zitadel_org_domain";
9
9
  export declare const ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY = "zitadel_org_registration_closed";
10
10
  /** Closes self-registration on the org (idempotent) and records it under `scopeKey`, in that order. */
11
11
  export declare function closeSignInOrgRegistration(zitadel: Pick<ZitadelPort, 'closeSelfRegistration'>, orgId: string, recordScopeId: (key: string, id: string) => void, scopeKey?: string): Promise<void>;
12
- /** Scope id: the domain the sign-in server refused to make primary (its domain policy wants proof). Set instead of failing; the planner reports it as blocked. */
12
+ /** Scope id: the domain the sign-in server still refused to make primary after foundry set its domain policy and added the domain again. Set instead of failing; the planner reports it and plans the domain again. */
13
13
  export declare const ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY = "zitadel_org_domain_blocked";
14
- /** The fixed text of the blocked item for a refused sign-in domain. No cause, no value but names. */
14
+ /** The fixed text of the note for a refused sign-in domain. No cause, no value but names. */
15
15
  export declare function signInDomainBlockedText(orgName: string, domain: string): string;
16
+ /**
17
+ * The domain policy a sign-in server needs before foundry gives an org its
18
+ * domain. The server holds only orgs foundry made, so a domain needs no proof
19
+ * of ownership, and a person's login name carries their org's domain.
20
+ */
21
+ export declare const SIGN_IN_DOMAIN_POLICY: {
22
+ userLoginMustBeDomain: boolean;
23
+ validateOrgDomains: boolean;
24
+ };
16
25
  /** The domain an org's owners sign in on: its own, or `<slug>.oikos.host` when it has none. */
17
26
  export declare function signInOrgDomain(org: {
18
27
  slug: string;
19
28
  domain: string | null;
20
29
  }): string;
21
30
  /**
22
- * Adds `domain` to the ZITADEL org and makes it primary. Both calls are
23
- * idempotent. A failure after the add leaves the domain on the org but not
24
- * primary, and nothing is recorded, so the next run repeats both.
31
+ * Sets the server's domain policy ({@link SIGN_IN_DOMAIN_POLICY}), adds
32
+ * `domain` to the ZITADEL org and makes it primary. Every call is idempotent.
33
+ * A domain added while the policy still wanted proof stays unverified, so it
34
+ * is removed and added again. A failure after the add leaves the domain on
35
+ * the org but not primary, and nothing is recorded, so the next run repeats.
25
36
  */
26
- export declare function registerSignInDomain(zitadel: Pick<ZitadelPort, 'addOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string, sleep?: (ms: number) => Promise<void>): Promise<void>;
37
+ export declare function registerSignInDomain(zitadel: Pick<ZitadelPort, 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string, sleep?: (ms: number) => Promise<void>): Promise<void>;
27
38
  /**
28
39
  * {@link registerSignInDomain}, except that a refusal to make the domain
29
- * primary (the sign-in server's domain policy wants proof) is recorded under
40
+ * primary (still unverified after the policy and the second add) is recorded under
30
41
  * `blockedScopeKey` and returns `false` instead of throwing on every apply.
31
42
  * Any other failure throws. Returns `true` when the domain is primary.
32
43
  */
33
- export declare function registerSignInDomainOrBlock(zitadel: Pick<ZitadelPort, 'addOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string, recordScopeId: (key: string, id: string) => void, blockedScopeKey?: string): Promise<boolean>;
44
+ export declare function registerSignInDomainOrBlock(zitadel: Pick<ZitadelPort, 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string, recordScopeId: (key: string, id: string) => void, blockedScopeKey?: string): Promise<boolean>;
34
45
  export interface EnsureSignInOrgOptions {
35
- zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'addOrgDomain' | 'setPrimaryOrgDomain' | 'closeSelfRegistration'>;
46
+ zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain' | 'closeSelfRegistration'>;
36
47
  /** Where the successful close is recorded (the org id). Absent: not recorded; the caller closes on every run. */
37
48
  registrationClosedScopeKey?: string;
38
49
  /** Defaults to {@link ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY}. */
@@ -11,12 +11,18 @@ export async function closeSignInOrgRegistration(zitadel, orgId, recordScopeId,
11
11
  await zitadel.closeSelfRegistration(orgId);
12
12
  recordScopeId(scopeKey, orgId);
13
13
  }
14
- /** Scope id: the domain the sign-in server refused to make primary (its domain policy wants proof). Set instead of failing; the planner reports it as blocked. */
14
+ /** Scope id: the domain the sign-in server still refused to make primary after foundry set its domain policy and added the domain again. Set instead of failing; the planner reports it and plans the domain again. */
15
15
  export const ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY = 'zitadel_org_domain_blocked';
16
- /** The fixed text of the blocked item for a refused sign-in domain. No cause, no value but names. */
16
+ /** The fixed text of the note for a refused sign-in domain. No cause, no value but names. */
17
17
  export function signInDomainBlockedText(orgName, domain) {
18
- return `${orgName}'s sign-in domain ${domain} is blocked, not planned: the sign-in server refused to make it primary because its domain policy wants proof of ownership. Set the policy ValidateOrgDomains to false (and UserLoginMustBeDomain to true) on the sign-in server, then remove the recorded key ${ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY}`;
18
+ return `${orgName}'s sign-in domain ${domain} was refused: the sign-in server would not make it primary even after foundry turned off its proof of ownership (ValidateOrgDomains) and added the domain again. The next apply tries again`;
19
19
  }
20
+ /**
21
+ * The domain policy a sign-in server needs before foundry gives an org its
22
+ * domain. The server holds only orgs foundry made, so a domain needs no proof
23
+ * of ownership, and a person's login name carries their org's domain.
24
+ */
25
+ export const SIGN_IN_DOMAIN_POLICY = { userLoginMustBeDomain: true, validateOrgDomains: false };
20
26
  /** True for the providers' "domain is not verified" refusal of `setPrimaryOrgDomain`. */
21
27
  function isPrimaryDomainRefused(error) {
22
28
  return error instanceof Error && /is not verified/.test(error.message);
@@ -31,30 +37,51 @@ const DOMAIN_LISTED_WAIT_MS = 500;
31
37
  export function signInOrgDomain(org) {
32
38
  return (org.domain ?? `${org.slug}.oikos.host`).toLowerCase();
33
39
  }
34
- /**
35
- * Adds `domain` to the ZITADEL org and makes it primary. Both calls are
36
- * idempotent. A failure after the add leaves the domain on the org but not
37
- * primary, and nothing is recorded, so the next run repeats both.
38
- */
39
- export async function registerSignInDomain(zitadel, orgId, domain, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
40
- await zitadel.addOrgDomain(orgId, domain);
41
- // The sign-in server lists a domain a moment after it accepts the add, and
42
- // `setPrimaryOrgDomain` reads that list first: ask again, for five seconds.
40
+ /** Retries `work` while `retry` accepts its error, for five seconds. */
41
+ async function untilListed(work, retry, sleep) {
43
42
  for (let attempt = 1;; attempt += 1) {
44
43
  try {
45
- await zitadel.setPrimaryOrgDomain(orgId, domain);
44
+ await work();
46
45
  return;
47
46
  }
48
47
  catch (error) {
49
- if (attempt >= DOMAIN_LISTED_TRIES || !isDomainNotListed(error))
48
+ if (attempt >= DOMAIN_LISTED_TRIES || !retry(error))
50
49
  throw error;
51
50
  await sleep(DOMAIN_LISTED_WAIT_MS);
52
51
  }
53
52
  }
54
53
  }
54
+ /**
55
+ * Sets the server's domain policy ({@link SIGN_IN_DOMAIN_POLICY}), adds
56
+ * `domain` to the ZITADEL org and makes it primary. Every call is idempotent.
57
+ * A domain added while the policy still wanted proof stays unverified, so it
58
+ * is removed and added again. A failure after the add leaves the domain on
59
+ * the org but not primary, and nothing is recorded, so the next run repeats.
60
+ */
61
+ export async function registerSignInDomain(zitadel, orgId, domain, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
62
+ await zitadel.ensureDomainPolicy(SIGN_IN_DOMAIN_POLICY);
63
+ await zitadel.addOrgDomain(orgId, domain);
64
+ // The sign-in server lists a domain a moment after it accepts the add, and
65
+ // `setPrimaryOrgDomain` reads that list first: ask again, for five seconds.
66
+ try {
67
+ await untilListed(() => zitadel.setPrimaryOrgDomain(orgId, domain), isDomainNotListed, sleep);
68
+ return;
69
+ }
70
+ catch (error) {
71
+ if (!isPrimaryDomainRefused(error))
72
+ throw error;
73
+ }
74
+ await zitadel.removeOrgDomain(orgId, domain);
75
+ // Until the server lists the removal, the add sees the old unverified
76
+ // domain and skips: both answers are a moment late, so ask again.
77
+ await untilListed(async () => {
78
+ await zitadel.addOrgDomain(orgId, domain);
79
+ await zitadel.setPrimaryOrgDomain(orgId, domain);
80
+ }, (error) => isDomainNotListed(error) || isPrimaryDomainRefused(error), sleep);
81
+ }
55
82
  /**
56
83
  * {@link registerSignInDomain}, except that a refusal to make the domain
57
- * primary (the sign-in server's domain policy wants proof) is recorded under
84
+ * primary (still unverified after the policy and the second add) is recorded under
58
85
  * `blockedScopeKey` and returns `false` instead of throwing on every apply.
59
86
  * Any other failure throws. Returns `true` when the domain is primary.
60
87
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.35.2",
3
+ "version": "0.36.1",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,7 +38,7 @@
38
38
  "status": "node scripts/status.mjs"
39
39
  },
40
40
  "peerDependencies": {
41
- "@wtfalch/providers": ">=0.7.4",
41
+ "@wtfalch/providers": ">=0.10.0",
42
42
  "drizzle-orm": ">=0.45.2"
43
43
  },
44
44
  "dependencies": {
@@ -46,7 +46,7 @@
46
46
  "@wtfalch/auth": "0.16.0",
47
47
  "@wtfalch/edge": "0.13.0",
48
48
  "@wtfalch/email": "^0.20.0",
49
- "@wtfalch/providers": "0.7.5",
49
+ "@wtfalch/providers": "0.10.0",
50
50
  "ssh2": "1.17.0",
51
51
  "zod": "4.6.5"
52
52
  },