@wtfalch/foundry 0.44.0 → 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 +11 -0
- package/dist/org.js +22 -0
- package/dist/plan/boule.d.ts +4 -0
- package/dist/plan/boule.js +4 -0
- package/dist/plan/broker-entries.d.ts +7 -0
- package/dist/plan/broker-entries.js +12 -1
- package/dist/plan/diff.d.ts +2 -0
- package/dist/plan/diff.js +56 -21
- package/dist/plan/executor.d.ts +18 -0
- package/dist/plan/executor.js +34 -1
- package/dist/plan/index.d.ts +1 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/plan-apply.d.ts +7 -0
- package/dist/plan/plan-apply.js +3 -0
- package/dist/plan/platform-providers.d.ts +1 -1
- package/dist/plan/platform-providers.js +2 -1
- package/dist/plan/zitadel-org-metadata.d.ts +18 -0
- package/dist/plan/zitadel-org-metadata.js +51 -0
- package/package.json +1 -1
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' ||
|
package/dist/plan/boule.d.ts
CHANGED
|
@@ -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. */
|
package/dist/plan/boule.js
CHANGED
|
@@ -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 {
|
|
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
|
}
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -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
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
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
|
-
|
|
3911
|
-
|
|
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
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
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>`,
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -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
|
package/dist/plan/executor.js
CHANGED
|
@@ -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();
|
package/dist/plan/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/plan/index.js
CHANGED
|
@@ -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;
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -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,
|
|
@@ -995,7 +995,7 @@ export interface PlatformArchonRuntime {
|
|
|
995
995
|
export declare const PLATFORM_ARCHON_ACCESS_APP_NAME = "foundry:platform:archon";
|
|
996
996
|
/**
|
|
997
997
|
* Archon paths a machine calls and Archon authenticates itself (its `proxy.ts`:
|
|
998
|
-
* `
|
|
998
|
+
* `PUBLIC_ROUTES` and the org route pattern in `src/lib/public-routes.ts`). Each gets its own Access app with
|
|
999
999
|
* one bypass policy, because the hostname app would otherwise ask a machine for
|
|
1000
1000
|
* an emailed code. `/api/uptime/targets` and `/api/uptime/results` are left out
|
|
1001
1001
|
* on purpose: they pass the hostname app through the uptime service-token
|
|
@@ -3722,7 +3722,7 @@ const PLATFORM_ARCHON_TUNNEL_SCOPE_KEY = 'cloudflare_platform_archon_tunnel';
|
|
|
3722
3722
|
const PLATFORM_ARCHON_ACCESS_SCOPE_KEY = 'cloudflare_platform_archon_access_app';
|
|
3723
3723
|
/**
|
|
3724
3724
|
* Archon paths a machine calls and Archon authenticates itself (its `proxy.ts`:
|
|
3725
|
-
* `
|
|
3725
|
+
* `PUBLIC_ROUTES` and the org route pattern in `src/lib/public-routes.ts`). Each gets its own Access app with
|
|
3726
3726
|
* one bypass policy, because the hostname app would otherwise ask a machine for
|
|
3727
3727
|
* an emailed code. `/api/uptime/targets` and `/api/uptime/results` are left out
|
|
3728
3728
|
* on purpose: they pass the hostname app through the uptime service-token
|
|
@@ -3736,6 +3736,7 @@ export const PLATFORM_ARCHON_PUBLIC_PATHS = [
|
|
|
3736
3736
|
{ name: 'usage-tick', path: '/api/usage/tick' },
|
|
3737
3737
|
{ name: 'reporting-collect', path: '/api/reporting/collect' },
|
|
3738
3738
|
{ name: 'org-changes', path: '/api/orgs/*/changes' },
|
|
3739
|
+
{ name: 'org-settings', path: '/api/orgs/*/settings' },
|
|
3739
3740
|
];
|
|
3740
3741
|
const PLATFORM_ARCHON_PUBLIC_POLICY_NAME = 'foundry: public path';
|
|
3741
3742
|
const archonPublicAppName = (name) => `${PLATFORM_ARCHON_ACCESS_APP_NAME}:public:${name}`;
|
|
@@ -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
|
+
}
|