@wtfalch/foundry 0.44.1 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/org.d.ts CHANGED
@@ -458,8 +458,19 @@ export interface EntryAuth {
458
458
  */
459
459
  mcp?: {
460
460
  path: string;
461
+ onboardingPath?: string;
461
462
  };
463
+ /**
464
+ * How the customer proves who they are. `password` is the default.
465
+ * `email-code` has the sign-in page mail a one-time code instead of asking
466
+ * for a password: the broker entry gets `emailLink: true` and the customers
467
+ * organisation gets the metadata `wtfalch.signIn = email-code`. Needs
468
+ * `users: 'customers'`.
469
+ */
470
+ signIn?: 'password' | 'email-code';
462
471
  }
472
+ /** `uses.auth.mcp.onboardingPath`: absolute, no query, no fragment, no dot segments. */
473
+ export declare const ONBOARDING_PATH_MAX_LENGTH = 200;
463
474
  /** The MCP endpoint path: absolute, plain characters, no dot segments, no query. */
464
475
  export declare const MCP_PATH: RegExp;
465
476
  /**
package/dist/org.js CHANGED
@@ -11,6 +11,8 @@ export const DEFAULT_GATE_LIMIT = 3;
11
11
  export const APP_ENTRY_KEY = 'app';
12
12
  /** The most volumes one entry may declare. */
13
13
  export const MAX_ENTRY_VOLUMES = 8;
14
+ /** `uses.auth.mcp.onboardingPath`: absolute, no query, no fragment, no dot segments. */
15
+ export const ONBOARDING_PATH_MAX_LENGTH = 200;
14
16
  /** The MCP endpoint path: absolute, plain characters, no dot segments, no query. */
15
17
  export const MCP_PATH = /^\/[A-Za-z0-9_~-]+(\/[A-Za-z0-9_~-]+)*$|^\/$/;
16
18
  /** What an entry's ai key may hold: the most the org's own key holds. */
@@ -556,6 +558,26 @@ function validateEntryAuth(entry, hosting, domain) {
556
558
  auth.mcp.path.length > 200) {
557
559
  return `${at} mcp.path must be an absolute path of letters, digits, '_', '~' and '-' segments, such as '/mcp'`;
558
560
  }
561
+ const onboarding = auth.mcp.onboardingPath;
562
+ if (onboarding !== undefined &&
563
+ (typeof onboarding !== 'string' ||
564
+ !onboarding.startsWith('/') ||
565
+ onboarding.startsWith('//') ||
566
+ onboarding.includes('..') ||
567
+ onboarding.includes('?') ||
568
+ onboarding.includes('#') ||
569
+ onboarding.includes('\\') ||
570
+ /[\s\p{Cc}]/u.test(onboarding) ||
571
+ onboarding.length > ONBOARDING_PATH_MAX_LENGTH)) {
572
+ return `${at} mcp.onboardingPath must be an absolute path that starts with '/', such as '/onboarding', with no '..', '?', '#' or spaces, and at most ${ONBOARDING_PATH_MAX_LENGTH} characters`;
573
+ }
574
+ }
575
+ const signIn = auth.signIn;
576
+ if (signIn !== undefined && signIn !== 'password' && signIn !== 'email-code') {
577
+ return `${at} signIn must be 'password' or 'email-code'`;
578
+ }
579
+ if (signIn === 'email-code' && auth.users !== 'customers') {
580
+ return `${at} signIn 'email-code' needs users 'customers': only customers sign in with an emailed code`;
559
581
  }
560
582
  if (entry.key === 'agora' ||
561
583
  entry.key === 'boule' ||
@@ -96,6 +96,10 @@ export declare const ARCHON_CREDENTIAL_RENEW_AFTER_MS: number;
96
96
  export declare function archonCredentialRenewDue(recorded: Readonly<Record<string, string>>, nowMs: number): boolean;
97
97
  /** The ZITADEL machine user that reads the customers organisation. No secret. */
98
98
  export declare const CUSTOMERS_DIRECTORY_USER_SCOPE_KEY = "customers_directory_user";
99
+ /** The ZITADEL org metadata key that tells the sign-in page how the customers organisation signs in. */
100
+ export declare const CUSTOMERS_SIGN_IN_METADATA_KEY = "wtfalch.signIn";
101
+ /** `email-code` or `password`: the sign-in metadata last written to the customers organisation. Absent: never written. */
102
+ export declare const CUSTOMERS_SIGN_IN_SCOPE_KEY = "zitadel_customers_signin";
99
103
  /** `1` or `0`: the sign-up switch last written to the customers organisation's login policy. */
100
104
  export declare const CUSTOMERS_SIGN_UP_SCOPE_KEY = "zitadel_customers_signup";
101
105
  /** Where the held-key store keeps the Archon credential of an org's Boule. */
@@ -110,6 +110,10 @@ export function archonCredentialRenewDue(recorded, nowMs) {
110
110
  }
111
111
  /** The ZITADEL machine user that reads the customers organisation. No secret. */
112
112
  export const CUSTOMERS_DIRECTORY_USER_SCOPE_KEY = 'customers_directory_user';
113
+ /** The ZITADEL org metadata key that tells the sign-in page how the customers organisation signs in. */
114
+ export const CUSTOMERS_SIGN_IN_METADATA_KEY = 'wtfalch.signIn';
115
+ /** `email-code` or `password`: the sign-in metadata last written to the customers organisation. Absent: never written. */
116
+ export const CUSTOMERS_SIGN_IN_SCOPE_KEY = 'zitadel_customers_signin';
113
117
  /** `1` or `0`: the sign-up switch last written to the customers organisation's login policy. */
114
118
  export const CUSTOMERS_SIGN_UP_SCOPE_KEY = 'zitadel_customers_signup';
115
119
  /** Where the held-key store keeps the Archon credential of an org's Boule. */
@@ -40,6 +40,7 @@ export declare function entryMcp(org: DeclaredOrg, entry: HostingEntry, recorded
40
40
  resource: string;
41
41
  projectId: string;
42
42
  loginHost: string;
43
+ onboardingUrl?: string;
43
44
  } | null;
44
45
  /** What the app's env says about who signs in, as recorded so far. Missing values are left out. */
45
46
  export declare function entryAuthEnv(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>, issuer: string | null): Record<string, string>;
@@ -59,7 +60,13 @@ export interface BrokerEntryWant {
59
60
  resource: string;
60
61
  projectId: string;
61
62
  loginHost: string;
63
+ onboardingUrl?: string;
62
64
  };
65
+ /**
66
+ * `true` for `signIn: 'email-code'`, `false` for an entry that declares `signIn: 'password'`.
67
+ * Absent when the entry declares no `signIn`: a hand-set `emailLink` stays as it is.
68
+ */
69
+ emailLink?: boolean;
63
70
  }
64
71
  /**
65
72
  * Each value a broker entry needs and the step that records it, so the
@@ -52,7 +52,12 @@ export function entryMcp(org, entry, recorded) {
52
52
  const host = entryHostnames(org, entry)[0];
53
53
  if (!mcp || !projectId || !host)
54
54
  return null;
55
- return { resource: `https://${host}${mcp.path}`, projectId, loginHost: loginHostnameFor(org) };
55
+ return {
56
+ resource: `https://${host}${mcp.path}`,
57
+ projectId,
58
+ loginHost: loginHostnameFor(org),
59
+ ...(mcp.onboardingPath ? { onboardingUrl: `https://${host}${mcp.onboardingPath}` } : {}),
60
+ };
56
61
  }
57
62
  /** What the app's env says about who signs in, as recorded so far. Missing values are left out. */
58
63
  export function entryAuthEnv(org, entry, recorded, issuer) {
@@ -111,6 +116,7 @@ export function wantedBrokerEntry(org, entry, recorded) {
111
116
  registration: auth.registration === 'open',
112
117
  replaces: auth.replacesBrokerEntry ?? null,
113
118
  ...(mcp ? { mcp } : {}),
119
+ ...(auth.signIn ? { emailLink: auth.signIn === 'email-code' } : {}),
114
120
  },
115
121
  };
116
122
  }
@@ -227,6 +233,11 @@ export async function writeBrokerEntry(broker, input) {
227
233
  entry.mcp = want.mcp;
228
234
  else
229
235
  entry.mcp = undefined;
236
+ // A hand-set `emailLink` stays unless the entry declares `signIn`, or declared it before and stopped.
237
+ if (want.emailLink !== undefined)
238
+ entry.emailLink = want.emailLink;
239
+ else if (previous?.emailLink === true)
240
+ entry.emailLink = false;
230
241
  try {
231
242
  validateBrokerAppEntry(entry, brokerAppId, 0);
232
243
  }
@@ -55,6 +55,8 @@ export interface PlanOrgOptions {
55
55
  zitadelIdTokenUserinfo?: boolean;
56
56
  /** The executor's ZITADEL client has `ensureOidcAppMcpAccess` and `ensureAccessTokenLifetime`; without them `planMcp` is blocked. */
57
57
  zitadelMcp?: boolean;
58
+ /** The executor's ZITADEL client has `setOrgMetadata` and `removeOrgMetadata`; without them the customers organisation's sign-in metadata is reported blocked. */
59
+ zitadelOrgMetadata?: boolean;
58
60
  /** F5: the executor was built with a `broker` port. */
59
61
  broker?: boolean;
60
62
  /** #161: the slugs of the other declared orgs, so a Boule whose hostname another org's slug would take is blocked. */
package/dist/plan/diff.js CHANGED
@@ -3,7 +3,7 @@ import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, held
3
3
  import { entryHasDatabase } from '../worker-app-config.js';
4
4
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
5
5
  import { planBlueGreen } from './blue-green.js';
6
- import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_MAIL_TOKEN_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, customersOrgIdOf, desiredBouleEnv, staleCustomersEnv, wantedBouleBrokerEntry, } from './boule.js';
6
+ import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_MAIL_TOKEN_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, customersOrgIdOf, desiredBouleEnv, staleCustomersEnv, wantedBouleBrokerEntry, } from './boule.js';
7
7
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
8
8
  import { chatInternalNames, chatUpstreams } from './chat-matrix.js';
9
9
  import { planCompanyBackups } from './company-backups.js';
@@ -400,9 +400,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
400
400
  // this array order, one at a time.
401
401
  // Customers before the entries that sign them in: `pushAppEnv` and the
402
402
  // broker entry read the customers organisation's id.
403
- const customers = planCustomers(org, live, claimed, recordedIds);
404
- if (customers)
405
- plannedSteps.push(customers);
403
+ const customers = planCustomers(org, live, claimed, recordedIds, options.zitadelOrgMetadata ?? false);
404
+ if (customers.step)
405
+ plannedSteps.push(customers.step);
406
406
  for (const step of [
407
407
  ...planIssuerApps(org, byKey, claimed),
408
408
  ...planEntryClients(org, byKey, claimed),
@@ -707,6 +707,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
707
707
  unmanaged.push(...edgeWorkers.blocked);
708
708
  unmanaged.push(...workerApps.blocked);
709
709
  unmanaged.push(...mcp.blocked);
710
+ unmanaged.push(...customers.blocked);
710
711
  unmanaged.push(...entryEdgeKeys.blocked);
711
712
  unmanaged.push(...edgeReservedLabels.blocked);
712
713
  if (ownAuth.blocked)
@@ -3879,21 +3880,38 @@ function planBouleBrokerEntry(org, recordedIds, plannedSteps, brokerWired) {
3879
3880
  * recorded, the live project grant to that org is claimed so it is not
3880
3881
  * listed as unmanaged.
3881
3882
  */
3882
- function planCustomers(org, live, claimed, recordedIds) {
3883
+ function planCustomers(org, live, claimed, recordedIds, metadataWired) {
3883
3884
  const signUp = org.customers?.signUp === true;
3884
3885
  const wanted = org.customers?.enabled === true ||
3885
3886
  org.hosting.entries.some((entry) => entry.uses?.auth?.users === 'customers');
3887
+ // The sign-in page reads this from the customers organisation: any entry with an emailed code turns it on.
3888
+ const wantedSignIn = wanted && org.hosting.entries.some((entry) => entry.uses?.auth?.signIn === 'email-code')
3889
+ ? 'email-code'
3890
+ : '';
3891
+ const blocked = [];
3886
3892
  const customersOrgId = recordedIds[resourceKey('zitadel', 'customers', org.id)];
3887
3893
  if (!customersOrgId) {
3888
3894
  if (!wanted)
3889
- return null;
3895
+ return { step: null, blocked };
3896
+ const signIn = wantedSignIn === 'email-code' && metadataWired ? { signIn: wantedSignIn } : {};
3897
+ if (wantedSignIn === 'email-code' && !metadataWired) {
3898
+ blocked.push(signInMetadataBlocked(org));
3899
+ }
3890
3900
  return {
3891
- action: { system: 'zitadel', kind: 'customers', key: org.id, desired: { signUp } },
3892
3901
  step: {
3893
- description: `provision a ZITADEL org for ${org.slug}'s customers, with self-registration ${signUp ? 'on' : 'off'}`,
3894
- destroys: false,
3895
- migrates: false,
3902
+ action: {
3903
+ system: 'zitadel',
3904
+ kind: 'customers',
3905
+ key: org.id,
3906
+ desired: { signUp, ...signIn },
3907
+ },
3908
+ step: {
3909
+ description: `provision a ZITADEL org for ${org.slug}'s customers, with self-registration ${signUp ? 'on' : 'off'}${signIn.signIn ? ' and sign-in by emailed code' : ''}`,
3910
+ destroys: false,
3911
+ migrates: false,
3912
+ },
3896
3913
  },
3914
+ blocked,
3897
3915
  };
3898
3916
  }
3899
3917
  for (const r of live) {
@@ -3907,22 +3925,39 @@ function planCustomers(org, live, claimed, recordedIds) {
3907
3925
  // marker is on. Switching customers off closes it: the declaration says no one signs themselves up.
3908
3926
  const applied = recordedIds[CUSTOMERS_SIGN_UP_SCOPE_KEY] || '1';
3909
3927
  const wantedSignUp = wanted && signUp ? '1' : '0';
3910
- if (applied === wantedSignUp)
3911
- return null;
3928
+ const appliedSignIn = recordedIds[CUSTOMERS_SIGN_IN_SCOPE_KEY] === 'email-code' ? 'email-code' : '';
3929
+ const signInDiffers = appliedSignIn !== wantedSignIn;
3930
+ if (signInDiffers && !metadataWired)
3931
+ blocked.push(signInMetadataBlocked(org));
3932
+ const writeSignIn = signInDiffers && metadataWired;
3933
+ if (applied === wantedSignUp && !writeSignIn)
3934
+ return { step: null, blocked };
3912
3935
  return {
3913
- action: {
3914
- system: 'zitadel',
3915
- kind: 'customers',
3916
- key: org.id,
3917
- desired: { signUp: wantedSignUp === '1' },
3918
- },
3919
3936
  step: {
3920
- description: `turn self-registration ${wantedSignUp === '1' ? 'on' : 'off'} for ${org.slug}'s customers`,
3921
- destroys: false,
3922
- migrates: false,
3937
+ action: {
3938
+ system: 'zitadel',
3939
+ kind: 'customers',
3940
+ key: org.id,
3941
+ desired: {
3942
+ signUp: wantedSignUp === '1',
3943
+ ...(writeSignIn ? { signIn: wantedSignIn } : {}),
3944
+ },
3945
+ },
3946
+ step: {
3947
+ description: applied === wantedSignUp
3948
+ ? `${wantedSignIn ? 'set' : 'remove'} the emailed-code sign-in metadata of ${org.slug}'s customers`
3949
+ : `turn self-registration ${wantedSignUp === '1' ? 'on' : 'off'} for ${org.slug}'s customers`,
3950
+ destroys: false,
3951
+ migrates: false,
3952
+ },
3923
3953
  },
3954
+ blocked,
3924
3955
  };
3925
3956
  }
3957
+ const signInMetadataBlocked = (org) => ({
3958
+ description: `${org.slug}'s customers organisation sign-in metadata is blocked, not planned: the host's ZITADEL client cannot set or remove organisation metadata`,
3959
+ key: `zitadel:customers-signin:${org.id}`,
3960
+ });
3926
3961
  /**
3927
3962
  * #246: one step per `hosting.access` rule -- a `bypass` or `service-token`
3928
3963
  * Access policy on a hostname the org serves (`share.<domain>`,
@@ -1215,6 +1215,17 @@ export interface ZitadelPort {
1215
1215
  ensureAccessTokenLifetime?(seconds: number): Promise<{
1216
1216
  changed: boolean;
1217
1217
  }>;
1218
+ /**
1219
+ * Sets one metadata key on an org (`wtfalch.signIn = email-code` on the
1220
+ * customers organisation). An upsert, so a repeat is no change. Optional, with
1221
+ * {@link ZitadelPort.removeOrgMetadata}: a host whose client lacks either gets
1222
+ * the customers organisation's sign-in metadata reported blocked
1223
+ * (`FoundryPlanApplyOptions.zitadelOrgMetadata`). `zitadel-org-metadata.ts`
1224
+ * is the real implementation.
1225
+ */
1226
+ setOrgMetadata?(orgId: string, key: string, value: string): Promise<void>;
1227
+ /** Removes one metadata key from an org. A key that is not there is done, not an error. */
1228
+ removeOrgMetadata?(orgId: string, key: string): Promise<void>;
1218
1229
  }
1219
1230
  /**
1220
1231
  * #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
@@ -2183,6 +2194,13 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2183
2194
  * a re-run after a crash finishes the job.
2184
2195
  */
2185
2196
  private zitadelCustomers;
2197
+ /**
2198
+ * `wtfalch.signIn = email-code` on the customers organisation while an entry
2199
+ * declares it, removed when none does. The planner puts `signIn` in the step
2200
+ * only when the recorded marker differs, so a step for another change touches
2201
+ * nothing here. Returns the marker value to record, or null for no change.
2202
+ */
2203
+ private writeCustomersSignInMetadata;
2186
2204
  private zitadelApp;
2187
2205
  /**
2188
2206
  * The app an earlier attempt's recreate already made under the same
@@ -9,7 +9,7 @@ import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
9
9
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
10
10
  import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, } from './blue-green-apply.js';
11
11
  import { bgKey, colorAppName, entrySubject, healthTokenName, liveAppNameFor, splitServers, } from './blue-green.js';
12
- import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
12
+ import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_SIGN_IN_METADATA_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
13
13
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, mcpCallbackUri, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
14
14
  import { recordBuiltCommit } from './build-record.js';
15
15
  import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
@@ -2235,9 +2235,42 @@ export class FoundryStepExecutor {
2235
2235
  else
2236
2236
  await this.ports.zitadel.closeSelfRegistration(customers.id);
2237
2237
  await this.ports.zitadel.findOrCreateProjectGrant(zitadelOrgId, projectId, customers.id);
2238
+ // The sign-in metadata comes before either marker: a failed write leaves both markers behind, so the next plan repeats the step.
2239
+ const signIn = await this.writeCustomersSignInMetadata(org, customers.id, action, context);
2238
2240
  context.recordScopeId(CUSTOMERS_SIGN_UP_SCOPE_KEY, signUp ? '1' : '0');
2241
+ if (signIn)
2242
+ context.recordScopeId(CUSTOMERS_SIGN_IN_SCOPE_KEY, signIn);
2239
2243
  return { resourceId: customers.id };
2240
2244
  }
2245
+ /**
2246
+ * `wtfalch.signIn = email-code` on the customers organisation while an entry
2247
+ * declares it, removed when none does. The planner puts `signIn` in the step
2248
+ * only when the recorded marker differs, so a step for another change touches
2249
+ * nothing here. Returns the marker value to record, or null for no change.
2250
+ */
2251
+ async writeCustomersSignInMetadata(org, customersOrgId, action, context) {
2252
+ const want = action.desired.signIn;
2253
+ if (want === undefined)
2254
+ return null;
2255
+ const { setOrgMetadata, removeOrgMetadata } = this.ports.zitadel;
2256
+ if (!setOrgMetadata || !removeOrgMetadata) {
2257
+ throw new Error(`zitadel:customers for ${org.slug}: the ZITADEL client cannot set organisation metadata`);
2258
+ }
2259
+ try {
2260
+ if (want === 'email-code') {
2261
+ await setOrgMetadata.call(this.ports.zitadel, customersOrgId, CUSTOMERS_SIGN_IN_METADATA_KEY, 'email-code');
2262
+ return 'email-code';
2263
+ }
2264
+ // Nothing to remove when it was never written.
2265
+ if (context.recordedIds()[CUSTOMERS_SIGN_IN_SCOPE_KEY] !== 'email-code')
2266
+ return null;
2267
+ await removeOrgMetadata.call(this.ports.zitadel, customersOrgId, CUSTOMERS_SIGN_IN_METADATA_KEY);
2268
+ return 'password';
2269
+ }
2270
+ catch {
2271
+ throw new Error(`zitadel:customers for ${org.slug}: writing the sign-in metadata of organisation ${customersOrgId} failed`);
2272
+ }
2273
+ }
2241
2274
  async zitadelApp(action, org, context) {
2242
2275
  const appType = action.desired.appType;
2243
2276
  const recorded = context.recordedIds();
@@ -34,6 +34,7 @@ export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManag
34
34
  export type { AiManagementKeyMinter, PlatformAiDeps, PlatformAiRuntime, } from './platform-providers.js';
35
35
  export { createZitadelAdminMinter } from './zitadel-machine-user.js';
36
36
  export { createZitadelLoginPolicy } from './zitadel-login-policy.js';
37
+ export { createZitadelOrgMetadata } from './zitadel-org-metadata.js';
37
38
  export { createZitadelProjectRoles } from './zitadel-project-roles.js';
38
39
  export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './platform-sfu.js';
39
40
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
@@ -18,6 +18,7 @@ export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEME
18
18
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
19
19
  export { createZitadelAdminMinter } from './zitadel-machine-user.js';
20
20
  export { createZitadelLoginPolicy } from './zitadel-login-policy.js';
21
+ export { createZitadelOrgMetadata } from './zitadel-org-metadata.js';
21
22
  export { createZitadelProjectRoles } from './zitadel-project-roles.js';
22
23
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
23
24
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
@@ -487,6 +487,12 @@ export interface FoundryPlanApplyOptions {
487
487
  * `uses.auth.mcp` is reported blocked.
488
488
  */
489
489
  zitadelMcp?: boolean;
490
+ /**
491
+ * True when the host's ZITADEL client has `setOrgMetadata` and `removeOrgMetadata`.
492
+ * Without it an entry's `signIn: 'email-code'` is reported blocked: the customers
493
+ * organisation cannot be told.
494
+ */
495
+ zitadelOrgMetadata?: boolean;
490
496
  /**
491
497
  * F5: true when the host's executor was built with a `broker` port.
492
498
  * Optional, and false without it: each entry's broker entry is then
@@ -641,6 +647,7 @@ export declare class FoundryPlanApply implements PlanApply {
641
647
  private readonly loginHostDiscovery;
642
648
  private readonly zitadelIdTokenUserinfo;
643
649
  private readonly zitadelMcp;
650
+ private readonly zitadelOrgMetadata;
644
651
  private readonly broker;
645
652
  private readonly edgeWorkers;
646
653
  private readonly workerApps;
@@ -162,6 +162,7 @@ export class FoundryPlanApply {
162
162
  loginHostDiscovery;
163
163
  zitadelIdTokenUserinfo;
164
164
  zitadelMcp;
165
+ zitadelOrgMetadata;
165
166
  broker;
166
167
  edgeWorkers;
167
168
  workerApps;
@@ -196,6 +197,7 @@ export class FoundryPlanApply {
196
197
  this.loginHostDiscovery = options.loginHostDiscovery;
197
198
  this.zitadelIdTokenUserinfo = options.zitadelIdTokenUserinfo ?? false;
198
199
  this.zitadelMcp = options.zitadelMcp ?? false;
200
+ this.zitadelOrgMetadata = options.zitadelOrgMetadata ?? false;
199
201
  this.broker = options.broker ?? false;
200
202
  this.edgeWorkers = options.edgeWorkers ?? false;
201
203
  this.workerApps = options.workerApps ?? false;
@@ -1220,6 +1222,7 @@ export class FoundryPlanApply {
1220
1222
  loginHostServesDiscovery,
1221
1223
  zitadelIdTokenUserinfo: this.zitadelIdTokenUserinfo,
1222
1224
  zitadelMcp: this.zitadelMcp,
1225
+ zitadelOrgMetadata: this.zitadelOrgMetadata,
1223
1226
  broker: ownsServices(org) && this.hostOwnServices && this.executor.ownBrokerPort
1224
1227
  ? await this.executor.ownBrokerPort(org, recorded)
1225
1228
  : this.broker,
@@ -0,0 +1,18 @@
1
+ import type { ZitadelPort } from './executor.js';
2
+ export interface ZitadelOrgMetadataOptions {
3
+ /** The ZITADEL instance's base URL. */
4
+ url: string;
5
+ /** Bearer token allowed to write an org's metadata. */
6
+ token: string;
7
+ fetchImpl?: typeof fetch;
8
+ instanceHost?: string;
9
+ timeoutMs?: number;
10
+ }
11
+ /**
12
+ * The real {@link ZitadelPort.setOrgMetadata} and {@link ZitadelPort.removeOrgMetadata}:
13
+ * ZITADEL's v1 management API, `POST` and `DELETE /management/v1/metadata/<key>`,
14
+ * org-scoped by `x-zitadel-orgid`. A set is one upsert with the value in base64,
15
+ * so a repeat is no change. A remove of a key that is not there (404) is done.
16
+ * An error names the status, never the body.
17
+ */
18
+ export declare function createZitadelOrgMetadata(options: ZitadelOrgMetadataOptions): Pick<ZitadelPort, 'setOrgMetadata' | 'removeOrgMetadata'>;
@@ -0,0 +1,51 @@
1
+ import { ZitadelHttpError } from './zitadel-machine-user.js';
2
+ const CALL_TIMEOUT_MS = 30_000;
3
+ /**
4
+ * The real {@link ZitadelPort.setOrgMetadata} and {@link ZitadelPort.removeOrgMetadata}:
5
+ * ZITADEL's v1 management API, `POST` and `DELETE /management/v1/metadata/<key>`,
6
+ * org-scoped by `x-zitadel-orgid`. A set is one upsert with the value in base64,
7
+ * so a repeat is no change. A remove of a key that is not there (404) is done.
8
+ * An error names the status, never the body.
9
+ */
10
+ export function createZitadelOrgMetadata(options) {
11
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
12
+ async function call(method, orgId, key, body) {
13
+ if (!orgId)
14
+ throw new Error('org metadata: orgId is required');
15
+ const response = await fetchImpl(`${options.url}/management/v1/metadata/${encodeURIComponent(key)}`, {
16
+ method,
17
+ signal: AbortSignal.timeout(options.timeoutMs ?? CALL_TIMEOUT_MS),
18
+ headers: {
19
+ authorization: `Bearer ${options.token}`,
20
+ 'content-type': 'application/json',
21
+ 'x-zitadel-orgid': orgId,
22
+ ...(options.instanceHost
23
+ ? {
24
+ 'x-zitadel-instance-host': options.instanceHost,
25
+ 'x-forwarded-host': options.instanceHost,
26
+ 'x-forwarded-proto': 'https',
27
+ }
28
+ : {}),
29
+ },
30
+ body: body === undefined ? undefined : JSON.stringify(body),
31
+ });
32
+ if (!response.ok) {
33
+ throw new ZitadelHttpError(response.status, `${method} org metadata '${key}' failed`);
34
+ }
35
+ }
36
+ return {
37
+ async setOrgMetadata(orgId, key, value) {
38
+ await call('POST', orgId, key, { value: Buffer.from(value, 'utf8').toString('base64') });
39
+ },
40
+ async removeOrgMetadata(orgId, key) {
41
+ try {
42
+ await call('DELETE', orgId, key);
43
+ }
44
+ catch (error) {
45
+ if (error instanceof ZitadelHttpError && error.status === 404)
46
+ return;
47
+ throw error;
48
+ }
49
+ },
50
+ };
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.44.1",
3
+ "version": "0.45.0",
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",