@wtfalch/foundry 0.70.1 → 0.72.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/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/modules/sign-in.js +81 -9
- package/dist/modules/types.d.ts +2 -0
- package/dist/org.d.ts +18 -1
- package/dist/org.js +40 -0
- package/dist/plan/boule.d.ts +15 -0
- package/dist/plan/boule.js +13 -0
- package/dist/plan/broker-registrar-token.d.ts +69 -0
- package/dist/plan/broker-registrar-token.js +215 -0
- package/dist/plan/broker-registrar.d.ts +2 -0
- package/dist/plan/broker-registrar.js +5 -1
- package/dist/plan/diff.d.ts +13 -0
- package/dist/plan/diff.js +26 -5
- package/dist/plan/executor.d.ts +82 -0
- package/dist/plan/executor.js +172 -10
- package/dist/plan/health-check-settings.d.ts +13 -0
- package/dist/plan/health-check-settings.js +17 -0
- package/dist/plan/own-diff.d.ts +32 -0
- package/dist/plan/own-diff.js +67 -10
- package/dist/plan/own-hostnames.d.ts +8 -0
- package/dist/plan/own-hostnames.js +14 -0
- package/dist/plan/own-services.d.ts +7 -0
- package/dist/plan/own-services.js +2 -0
- package/dist/plan/plan-apply.d.ts +36 -0
- package/dist/plan/plan-apply.js +91 -0
- package/dist/plan/platform-diff.d.ts +9 -5
- package/dist/plan/platform-diff.js +16 -10
- package/dist/plan/platform-providers.d.ts +24 -2
- package/dist/plan/platform-providers.js +155 -12
- package/dist/plan/signin-mail.d.ts +18 -5
- package/dist/plan/signin-mail.js +14 -6
- package/dist/plan/zitadel-login-policy.d.ts +1 -1
- package/dist/plan/zitadel-login-policy.js +72 -46
- package/dist/plan/zitadel-machine-user.d.ts +14 -1
- package/dist/plan/zitadel-machine-user.js +96 -13
- package/dist/platform.d.ts +3 -0
- package/dist/release/fanout.d.ts +77 -0
- package/dist/release/fanout.js +177 -0
- package/dist/release/run.js +2 -1
- package/dist/release/state.d.ts +3 -1
- package/dist/release/state.js +3 -0
- package/dist/release/types.d.ts +9 -1
- package/dist/store/schema.d.ts +4 -0
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, HostingGithub, BlueGreenConfig, OrgBuiltinApp, BouleApp, KosmosApp, OrgRefusal, OrgRefusalCode, WorkerAppConfig, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryAuthRoom, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceDatabase, OrgServiceSchema, EntryWorkspace, ServicesConfig, SignInConfig, VersionPin, VersionPinPart, } from './org.js';
|
|
1
|
+
export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, HostingGithub, BlueGreenConfig, OrgBuiltinApp, BouleApp, KosmosApp, OrgRefusal, OrgRefusalCode, WorkerAppConfig, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryAuthRoom, SecondFactorMode, SignInMethod, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceDatabase, OrgServiceSchema, EntryWorkspace, ServicesConfig, SignInConfig, VersionPin, VersionPinPart, } from './org.js';
|
|
2
2
|
export { APP_ENTRY_KEY, APPS_DOMAIN, DEFAULT_GATE_LIMIT, MAX_WORKSPACE_TESTERS, isLiveEntry, normalizeTesters, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, orgServiceDatabase, orgServiceDatabases, SFU_SLOT_MAX, VERSION_PIN_PARTS, isMainEntry, mainEntry, companyGithubFor, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, normalizeHostingRepos, validateMail, validateOwners, validateServices, validateCustomers, validateSignIn, validateVersionPin, validateSite, } from './org.js';
|
|
3
3
|
export { refuseOrg } from './refuse-org.js';
|
|
4
4
|
export { workerAppHasBuild } from './worker-app-config.js';
|
|
@@ -25,6 +25,8 @@ export { createPostgresAuditLog } from './store/audit.js';
|
|
|
25
25
|
export type { Color, ReleaseAction, ReleaseEvent, ReleaseEventKind, ReleaseSeries, ReleaseSeriesColor, ReleaseSignal, ReleaseState, WindowState, } from './release/types.js';
|
|
26
26
|
export type { ReleaseStore } from './release/state.js';
|
|
27
27
|
export { readReleaseHistory, readReleaseState, requestReleaseAction } from './release/state.js';
|
|
28
|
+
export type { BuiltinFanOut, FanOutApp, FanOutOrgState } from './release/fanout.js';
|
|
29
|
+
export { readBuiltinFanOut } from './release/fanout.js';
|
|
28
30
|
export type { ReleaseSeriesHost, ReleaseSeriesQuery } from './release/series.js';
|
|
29
31
|
export { readReleaseSeries } from './release/series.js';
|
|
30
32
|
export type { EntrySeriesDeps } from './plan/release-series-reader.js';
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { StaleLeaseError } from './store.js';
|
|
|
15
15
|
export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
16
16
|
export { createPostgresAuditLog } from './store/audit.js';
|
|
17
17
|
export { readReleaseHistory, readReleaseState, requestReleaseAction } from './release/state.js';
|
|
18
|
+
export { readBuiltinFanOut } from './release/fanout.js';
|
|
18
19
|
export { readReleaseSeries } from './release/series.js';
|
|
19
20
|
export { readEntryReleaseSeries } from './plan/release-series-reader.js';
|
|
20
21
|
export { checkMigrationCompatibility } from './release/migration-compatibility.js';
|
package/dist/modules/sign-in.js
CHANGED
|
@@ -69,15 +69,53 @@ export const signIn = {
|
|
|
69
69
|
],
|
|
70
70
|
},
|
|
71
71
|
{
|
|
72
|
-
key: '
|
|
72
|
+
key: 'methods',
|
|
73
73
|
label: 'How people sign in',
|
|
74
|
+
help: 'Pick one or more; people choose among them. With none picked, people sign in with a password.',
|
|
75
|
+
kind: 'list',
|
|
76
|
+
max: 3,
|
|
77
|
+
choices: [
|
|
78
|
+
{ value: 'email-code', label: 'A code sent by email', help: 'No password to remember.' },
|
|
79
|
+
{ value: 'password', label: 'A password' },
|
|
80
|
+
{
|
|
81
|
+
value: 'passkey',
|
|
82
|
+
label: 'A passkey',
|
|
83
|
+
help: 'Sign in with a fingerprint, face or device lock. Pair it with a code or a password, so people can set it up and get back in.',
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: 'secondFactor',
|
|
89
|
+
label: 'A second step after sign-in',
|
|
90
|
+
help: 'An authenticator app or a security key. A passkey that checks the person already counts.',
|
|
74
91
|
kind: 'choice',
|
|
75
92
|
required: true,
|
|
76
|
-
default: '
|
|
93
|
+
default: 'off',
|
|
94
|
+
showWhen: forCustomers,
|
|
77
95
|
choices: [
|
|
78
|
-
{ value: '
|
|
96
|
+
{ value: 'off', label: 'No second step', help: 'People sign in with the methods above.' },
|
|
97
|
+
{
|
|
98
|
+
value: 'allowed',
|
|
99
|
+
label: 'Offer it',
|
|
100
|
+
help: 'People who set up a second step are asked for it.',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
value: 'required',
|
|
104
|
+
label: 'Everyone',
|
|
105
|
+
help: 'We help each person set one up the first time they sign in.',
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
key: 'method',
|
|
111
|
+
label: 'How people sign in (older setting)',
|
|
112
|
+
help: 'Replaced by "How people sign in". Kept so manifests written before it still work.',
|
|
113
|
+
kind: 'choice',
|
|
114
|
+
hidden: true,
|
|
115
|
+
choices: [
|
|
116
|
+
{ value: 'email-code', label: 'A code sent by email' },
|
|
79
117
|
{ value: 'password', label: 'A password' },
|
|
80
|
-
{ value: 'passkey', label: 'A passkey'
|
|
118
|
+
{ value: 'passkey', label: 'A passkey' },
|
|
81
119
|
],
|
|
82
120
|
},
|
|
83
121
|
roomField('roomName', 'Name on the sign-in page', 'The name of your app or company, as people know it.'),
|
|
@@ -107,8 +145,23 @@ export const signIn = {
|
|
|
107
145
|
if (!customers && values.registration === 'open') {
|
|
108
146
|
return problem('registration', 'Only customers can make their own account. For your team, choose "Only people you invite".');
|
|
109
147
|
}
|
|
110
|
-
if (
|
|
111
|
-
return problem('
|
|
148
|
+
if (Array.isArray(values.methods) && values.methods.length === 0) {
|
|
149
|
+
return problem('methods', 'Pick at least one way for people to sign in.');
|
|
150
|
+
}
|
|
151
|
+
const methods = methodsOf(values);
|
|
152
|
+
if (values.method !== undefined && values.methods !== undefined && !sameMethods(values)) {
|
|
153
|
+
return problem('methods', 'Sign-in is set twice with different answers. Use only "How people sign in".');
|
|
154
|
+
}
|
|
155
|
+
if (!customers && (methods.length !== 1 || methods[0] !== 'password')) {
|
|
156
|
+
return problem('methods', methods.includes('email-code')
|
|
157
|
+
? 'A code by email is for customers. Your team signs in with a password.'
|
|
158
|
+
: 'Your team signs in with a password.');
|
|
159
|
+
}
|
|
160
|
+
if (!customers && values.secondFactor !== undefined && values.secondFactor !== 'off') {
|
|
161
|
+
return problem('secondFactor', 'The second step for your team is set for the whole company, not per app.');
|
|
162
|
+
}
|
|
163
|
+
if (methods.length === 1 && methods[0] === 'passkey') {
|
|
164
|
+
return problem('methods', 'A passkey works together with another way in. Add a code by email or a password, so people can set up the passkey and get back in.');
|
|
112
165
|
}
|
|
113
166
|
const roomSet = [...Object.keys(ROOM_TEXT_KEYS), 'roomSteps'].some((key) => values[key] !== undefined && values[key] !== '');
|
|
114
167
|
if (!roomSet)
|
|
@@ -141,14 +194,21 @@ export const signIn = {
|
|
|
141
194
|
return withWorkerApp(next, (app) => omit(app, 'authEnv'));
|
|
142
195
|
}
|
|
143
196
|
// The parts other modules own (mcp, workspaces) and the operator's replacesBrokerEntry stay.
|
|
144
|
-
const kept = omit(entry.uses?.auth ?? {}, 'signIn', 'room');
|
|
197
|
+
const kept = omit(entry.uses?.auth ?? {}, 'signIn', 'signIns', 'secondFactor', 'room');
|
|
145
198
|
const auth = {
|
|
146
199
|
...kept,
|
|
147
200
|
users: values.users,
|
|
148
201
|
registration: values.registration,
|
|
149
202
|
};
|
|
150
|
-
|
|
203
|
+
const methods = methodsOf(values);
|
|
204
|
+
if (methods.includes('email-code'))
|
|
151
205
|
auth.signIn = 'email-code';
|
|
206
|
+
// One method is already what `signIn` says (or password by default), so only a mix is written.
|
|
207
|
+
if (methods.length > 1 || methods[0] === 'passkey')
|
|
208
|
+
auth.signIns = methods;
|
|
209
|
+
const secondFactor = values.secondFactor;
|
|
210
|
+
if (secondFactor !== undefined && secondFactor !== 'off')
|
|
211
|
+
auth.secondFactor = secondFactor;
|
|
152
212
|
const room = roomOf(values);
|
|
153
213
|
if (room)
|
|
154
214
|
auth.room = room;
|
|
@@ -162,7 +222,8 @@ export const signIn = {
|
|
|
162
222
|
const values = {
|
|
163
223
|
users: auth.users,
|
|
164
224
|
registration: auth.registration,
|
|
165
|
-
|
|
225
|
+
methods: auth.signIns ?? [auth.signIn ?? 'password'],
|
|
226
|
+
secondFactor: auth.secondFactor ?? 'off',
|
|
166
227
|
};
|
|
167
228
|
const room = auth.room;
|
|
168
229
|
if (room) {
|
|
@@ -178,6 +239,17 @@ export const signIn = {
|
|
|
178
239
|
return values;
|
|
179
240
|
},
|
|
180
241
|
};
|
|
242
|
+
/** The methods the values name: `methods`, else the older single `method`, else password. */
|
|
243
|
+
function methodsOf(values) {
|
|
244
|
+
if (Array.isArray(values.methods) && values.methods.length > 0) {
|
|
245
|
+
return values.methods;
|
|
246
|
+
}
|
|
247
|
+
return [values.method ?? 'password'];
|
|
248
|
+
}
|
|
249
|
+
const sameMethods = (values) => {
|
|
250
|
+
const methods = values.methods;
|
|
251
|
+
return methods.length === 1 && methods[0] === values.method;
|
|
252
|
+
};
|
|
181
253
|
function roomOf(values) {
|
|
182
254
|
const name = text(values, 'roomName');
|
|
183
255
|
const headline = text(values, 'roomHeadline');
|
package/dist/modules/types.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface ModuleField {
|
|
|
21
21
|
/** For `choice`: the one value picked. For `list`: every item must be one of them. */
|
|
22
22
|
choices?: ModuleChoice[];
|
|
23
23
|
default?: string | number | boolean | string[];
|
|
24
|
+
/** Accepted but not shown: a setting an older manifest may still carry. */
|
|
25
|
+
hidden?: boolean;
|
|
24
26
|
/** Shown under "Advanced". */
|
|
25
27
|
advanced?: boolean;
|
|
26
28
|
/**
|
package/dist/org.d.ts
CHANGED
|
@@ -301,7 +301,7 @@ export type AccessRule = {
|
|
|
301
301
|
/** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
|
|
302
302
|
export declare const APP_ENTRY_KEY = "app";
|
|
303
303
|
/** Why a validator refused. `code` is stable and never reworded; `message` is for people. */
|
|
304
|
-
export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'issuer_app_name_reserved_kosmos' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'entry_image_invalid' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'hosting_repo_invalid' | 'hosting_github_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'hosted_name_invalid' | 'hosted_name_not_unique' | 'hosted_name_worker_app' | 'entry_workspace_invalid' | 'entry_workspace_unsupported' | 'entry_live_needs_workspace' | 'entry_live_commit_invalid' | 'gate_key_taken_by_workspace' | 'gate_key_taken_by_private_page' | 'private_page_invalid' | 'private_page_path_invalid' | 'private_page_duplicate' | 'private_page_limit' | 'private_page_needs_hosting' | 'private_page_roles_invalid' | 'private_page_people_invalid' | 'entry_key_invalid' | 'entry_key_reserved' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'builtin_app_invalid' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'blue_green_advance_invalid' | 'blue_green_min_requests_invalid' | 'blue_green_error_margin_invalid' | 'blue_green_staff_first_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'mcp_events_path_invalid' | 'mcp_manage_path_invalid' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | 'auth_room_invalid' | 'auth_room_needs_customers' | 'auth_room_not_unique' | 'client_name_taken' | 'workspaces_needs_customers' | 'workspaces_needs_server' | 'workspaces_roles_invalid' | 'workspaces_role_reserved' | 'workspaces_invite_path_invalid' | 'workspaces_env_clash' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_needs_own_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'mail_not_true' | 'mail_needs_hosting' | 'mail_env_clash' | 'jobs_not_true' | 'jobs_needs_hosting' | 'jobs_env_clash' | 'analytics_not_true' | 'analytics_needs_hosting' | 'analytics_env_clash' | 'customer_grants_not_true' | 'customer_grants_needs_customers' | 'customer_grants_env_clash' | 'repo_use_invalid' | 'repo_use_needs_repo' | 'repo_use_env_clash' | 'audit_not_true' | 'audit_needs_hosting' | 'audit_needs_worker_database' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
|
|
304
|
+
export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'issuer_app_name_reserved_kosmos' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'entry_image_invalid' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'hosting_repo_invalid' | 'hosting_github_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'hosted_name_invalid' | 'hosted_name_not_unique' | 'hosted_name_worker_app' | 'entry_workspace_invalid' | 'entry_workspace_unsupported' | 'entry_live_needs_workspace' | 'entry_live_commit_invalid' | 'gate_key_taken_by_workspace' | 'gate_key_taken_by_private_page' | 'private_page_invalid' | 'private_page_path_invalid' | 'private_page_duplicate' | 'private_page_limit' | 'private_page_needs_hosting' | 'private_page_roles_invalid' | 'private_page_people_invalid' | 'entry_key_invalid' | 'entry_key_reserved' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'builtin_app_invalid' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'blue_green_advance_invalid' | 'blue_green_min_requests_invalid' | 'blue_green_error_margin_invalid' | 'blue_green_staff_first_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'mcp_events_path_invalid' | 'mcp_manage_path_invalid' | 'auth_sign_in_invalid' | 'auth_sign_ins_invalid' | 'auth_second_factor_invalid' | 'sign_in_staff_password_only' | 'passkey_needs_another_method' | 'email_code_needs_customers' | 'email_code_needs_sign_in' | 'auth_room_invalid' | 'auth_room_needs_customers' | 'auth_room_not_unique' | 'client_name_taken' | 'workspaces_needs_customers' | 'workspaces_needs_server' | 'workspaces_roles_invalid' | 'workspaces_role_reserved' | 'workspaces_invite_path_invalid' | 'workspaces_env_clash' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_needs_own_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'mail_not_true' | 'mail_needs_hosting' | 'mail_env_clash' | 'jobs_not_true' | 'jobs_needs_hosting' | 'jobs_env_clash' | 'analytics_not_true' | 'analytics_needs_hosting' | 'analytics_env_clash' | 'customer_grants_not_true' | 'customer_grants_needs_customers' | 'customer_grants_env_clash' | 'repo_use_invalid' | 'repo_use_needs_repo' | 'repo_use_env_clash' | 'audit_not_true' | 'audit_needs_hosting' | 'audit_needs_worker_database' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
|
|
305
305
|
/**
|
|
306
306
|
* One refusal: a stable `code`, the dotted `path` into {@link DeclaredOrg} it is about
|
|
307
307
|
* (`hosting.entries.terminal.uses.auth.registration`) and the sentence for people.
|
|
@@ -641,6 +641,19 @@ export interface EntryAuth {
|
|
|
641
641
|
* `users: 'customers'`.
|
|
642
642
|
*/
|
|
643
643
|
signIn?: 'password' | 'email-code';
|
|
644
|
+
/**
|
|
645
|
+
* The ways this app lets people sign in: one to three of `email-code`, `password` and `passkey`, no
|
|
646
|
+
* repeats. Absent means `signIn`, or `password`. `passkey` needs another method beside it, because a
|
|
647
|
+
* first passkey and its recovery both go through an emailed code or a password. A staff entry
|
|
648
|
+
* (`users: 'staff'`) may only have `['password']`. When both are set, `signIn` must be one of them.
|
|
649
|
+
*/
|
|
650
|
+
signIns?: SignInMethod[];
|
|
651
|
+
/**
|
|
652
|
+
* Whether the app asks for a second step (an authenticator app or a security key): `off` (the default),
|
|
653
|
+
* `allowed` (asked of people who set one up) or `required`. A user-verified passkey counts as the second
|
|
654
|
+
* step. Staff entries may only have `off`: staff 2FA belongs to the company login policy.
|
|
655
|
+
*/
|
|
656
|
+
secondFactor?: SecondFactorMode;
|
|
644
657
|
/**
|
|
645
658
|
* The entry's own customers get workspaces in the broker: they make one, invite people by email and give
|
|
646
659
|
* each member one of `roles`. The first role is the owner role. `invitePath` is the page on the entry's
|
|
@@ -659,6 +672,10 @@ export interface EntryAuth {
|
|
|
659
672
|
*/
|
|
660
673
|
room?: EntryAuthRoom;
|
|
661
674
|
}
|
|
675
|
+
export type SignInMethod = 'email-code' | 'password' | 'passkey';
|
|
676
|
+
export declare const SIGN_IN_METHODS: readonly SignInMethod[];
|
|
677
|
+
export type SecondFactorMode = 'off' | 'allowed' | 'required';
|
|
678
|
+
export declare const SECOND_FACTOR_MODES: readonly SecondFactorMode[];
|
|
662
679
|
/** The env names `uses.auth.workspaces` writes; `env` may not name them. */
|
|
663
680
|
export declare const WORKSPACES_ENTRY_ENV_NAMES: readonly ["WORKSPACES_URL", "WORKSPACES_API_KEY"];
|
|
664
681
|
export interface EntryAuthRoom {
|
package/dist/org.js
CHANGED
|
@@ -55,6 +55,8 @@ export function withNewOrgAppsDefault(org) {
|
|
|
55
55
|
export const kosmosOn = (org) => org.kosmos?.enabled === true;
|
|
56
56
|
/** The most volumes one entry may declare. */
|
|
57
57
|
export const MAX_ENTRY_VOLUMES = 8;
|
|
58
|
+
export const SIGN_IN_METHODS = ['email-code', 'password', 'passkey'];
|
|
59
|
+
export const SECOND_FACTOR_MODES = ['off', 'allowed', 'required'];
|
|
58
60
|
/** The env names `uses.auth.workspaces` writes; `env` may not name them. */
|
|
59
61
|
export const WORKSPACES_ENTRY_ENV_NAMES = ['WORKSPACES_URL', 'WORKSPACES_API_KEY'];
|
|
60
62
|
/** The env names `uses.ai` writes; `env` may not name them. */
|
|
@@ -971,6 +973,44 @@ function validateEntryAuth(entry, hosting, domain) {
|
|
|
971
973
|
if (signIn !== undefined && signIn !== 'password' && signIn !== 'email-code') {
|
|
972
974
|
return refusal('auth_sign_in_invalid', `hosting.entries.${entry.key}.uses.auth.signIn`, `${at} signIn must be 'password' or 'email-code'`);
|
|
973
975
|
}
|
|
976
|
+
const signIns = auth.signIns;
|
|
977
|
+
const signInsAt = `hosting.entries.${entry.key}.uses.auth.signIns`;
|
|
978
|
+
if (signIns !== undefined) {
|
|
979
|
+
if (!Array.isArray(signIns) ||
|
|
980
|
+
signIns.length < 1 ||
|
|
981
|
+
signIns.length > SIGN_IN_METHODS.length ||
|
|
982
|
+
!signIns.every((m) => SIGN_IN_METHODS.includes(m)) ||
|
|
983
|
+
new Set(signIns).size !== signIns.length) {
|
|
984
|
+
return refusal('auth_sign_ins_invalid', signInsAt, `${at} signIns must list one to three different methods of 'email-code', 'password' and 'passkey'`);
|
|
985
|
+
}
|
|
986
|
+
const methods = signIns;
|
|
987
|
+
if (signIn !== undefined && !methods.includes(signIn)) {
|
|
988
|
+
return refusal('auth_sign_ins_invalid', signInsAt, `${at} signIns must include signIn '${String(signIn)}'`);
|
|
989
|
+
}
|
|
990
|
+
if (auth.users !== 'customers' && (methods.length !== 1 || methods[0] !== 'password')) {
|
|
991
|
+
return refusal('sign_in_staff_password_only', signInsAt, `${at} signIns for users 'staff' must be ['password']: your team signs in with a password`);
|
|
992
|
+
}
|
|
993
|
+
if (methods.length === 1 && methods[0] === 'passkey') {
|
|
994
|
+
return refusal('passkey_needs_another_method', signInsAt, `${at} signIns 'passkey' needs 'email-code' or 'password' beside it: a first passkey and its recovery both use one of them`);
|
|
995
|
+
}
|
|
996
|
+
if (methods.includes('email-code') && auth.users !== 'customers') {
|
|
997
|
+
return refusal('email_code_needs_customers', signInsAt, `${at} signIns 'email-code' needs users 'customers': only customers sign in with an emailed code`);
|
|
998
|
+
}
|
|
999
|
+
// Every reader of the emailed code (the sign-in metadata, the broker entry, the workspace sign-in) reads
|
|
1000
|
+
// `signIn`, so a list that offers the code without it would plan none of them.
|
|
1001
|
+
if (methods.includes('email-code') && signIn !== 'email-code') {
|
|
1002
|
+
return refusal('email_code_needs_sign_in', signInsAt, `${at} signIns 'email-code' needs signIn 'email-code' too: the emailed code is set up from signIn`);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
const secondFactor = auth.secondFactor;
|
|
1006
|
+
if (secondFactor !== undefined) {
|
|
1007
|
+
if (!SECOND_FACTOR_MODES.includes(secondFactor)) {
|
|
1008
|
+
return refusal('auth_second_factor_invalid', `hosting.entries.${entry.key}.uses.auth.secondFactor`, `${at} secondFactor must be 'off', 'allowed' or 'required'`);
|
|
1009
|
+
}
|
|
1010
|
+
if (secondFactor !== 'off' && auth.users !== 'customers') {
|
|
1011
|
+
return refusal('sign_in_staff_password_only', `hosting.entries.${entry.key}.uses.auth.secondFactor`, `${at} secondFactor for users 'staff' must be 'off': staff two-step sign-in is set for the whole company`);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
974
1014
|
if (signIn === 'email-code' && auth.users !== 'customers') {
|
|
975
1015
|
return refusal('email_code_needs_customers', `hosting.entries.${entry.key}.uses.auth.signIn`, `${at} signIn 'email-code' needs users 'customers': only customers sign in with an emailed code`);
|
|
976
1016
|
}
|
package/dist/plan/boule.d.ts
CHANGED
|
@@ -108,10 +108,23 @@ export declare const ARCHON_CREDENTIAL_RENEW_AFTER_MS: number;
|
|
|
108
108
|
export declare function archonCredentialRenewDue(recorded: Readonly<Record<string, string>>, nowMs: number): boolean;
|
|
109
109
|
/** The ZITADEL machine user that reads the customers organisation. No secret. */
|
|
110
110
|
export declare const CUSTOMERS_DIRECTORY_USER_SCOPE_KEY = "customers_directory_user";
|
|
111
|
+
/**
|
|
112
|
+
* The roles the customers directory user holds in the customers organisation, recorded once they are granted.
|
|
113
|
+
* The value is the version: raise it and every stack plans the grant again. `1` is read plus user management
|
|
114
|
+
* (Boule removes a customer with this credential, boule#495).
|
|
115
|
+
*/
|
|
116
|
+
export declare const CUSTOMERS_DIRECTORY_ROLES_SCOPE_KEY = "customers_directory_roles";
|
|
117
|
+
export declare const CUSTOMERS_DIRECTORY_ROLES_VERSION = "1";
|
|
111
118
|
/** The ZITADEL org metadata key that tells the sign-in page how the customers organisation signs in. */
|
|
112
119
|
export declare const CUSTOMERS_SIGN_IN_METADATA_KEY = "wtfalch.signIn";
|
|
113
120
|
/** `email-code` or `password`: the sign-in metadata last written to the customers organisation. Absent: never written. */
|
|
114
121
|
export declare const CUSTOMERS_SIGN_IN_SCOPE_KEY = "zitadel_customers_signin";
|
|
122
|
+
/**
|
|
123
|
+
* The customers organisation's login policy (every customer app's superset), recorded once it is written.
|
|
124
|
+
* The value is the policy's version: raise it and every stack plans the write again.
|
|
125
|
+
*/
|
|
126
|
+
export declare const CUSTOMERS_POLICY_SCOPE_KEY = "zitadel_customers_policy";
|
|
127
|
+
export declare const CUSTOMERS_POLICY_VERSION = "1";
|
|
115
128
|
/** The ZITADEL org metadata key that holds the left-half text of the customers' sign-in window. */
|
|
116
129
|
export declare const CUSTOMERS_ROOM_METADATA_KEY = "wtfalch.room";
|
|
117
130
|
/** The metadata value last written to the customers organisation, or empty after a removal. Absent: never written. */
|
|
@@ -134,6 +147,8 @@ export declare const customersOrgIdOf: (org: Pick<DeclaredOrg, "id">, recorded:
|
|
|
134
147
|
export interface BouleCredentialWants {
|
|
135
148
|
archonCredential?: boolean;
|
|
136
149
|
customersDirectory?: boolean;
|
|
150
|
+
/** The directory user was made before it could remove customers: the step grants it that role in the customers organisation once. */
|
|
151
|
+
directoryRolesDue?: boolean;
|
|
137
152
|
/** The recorded Archon credential is missing or 335 days old: the step mints it again. */
|
|
138
153
|
archonRenewDue?: boolean;
|
|
139
154
|
/** F13: Boule is given `CUSTOMER_GRANTS` (an entry declares `uses.customerGrants`; needs `customersDirectory`). */
|
package/dist/plan/boule.js
CHANGED
|
@@ -124,10 +124,23 @@ export function archonCredentialRenewDue(recorded, nowMs) {
|
|
|
124
124
|
}
|
|
125
125
|
/** The ZITADEL machine user that reads the customers organisation. No secret. */
|
|
126
126
|
export const CUSTOMERS_DIRECTORY_USER_SCOPE_KEY = 'customers_directory_user';
|
|
127
|
+
/**
|
|
128
|
+
* The roles the customers directory user holds in the customers organisation, recorded once they are granted.
|
|
129
|
+
* The value is the version: raise it and every stack plans the grant again. `1` is read plus user management
|
|
130
|
+
* (Boule removes a customer with this credential, boule#495).
|
|
131
|
+
*/
|
|
132
|
+
export const CUSTOMERS_DIRECTORY_ROLES_SCOPE_KEY = 'customers_directory_roles';
|
|
133
|
+
export const CUSTOMERS_DIRECTORY_ROLES_VERSION = '1';
|
|
127
134
|
/** The ZITADEL org metadata key that tells the sign-in page how the customers organisation signs in. */
|
|
128
135
|
export const CUSTOMERS_SIGN_IN_METADATA_KEY = 'wtfalch.signIn';
|
|
129
136
|
/** `email-code` or `password`: the sign-in metadata last written to the customers organisation. Absent: never written. */
|
|
130
137
|
export const CUSTOMERS_SIGN_IN_SCOPE_KEY = 'zitadel_customers_signin';
|
|
138
|
+
/**
|
|
139
|
+
* The customers organisation's login policy (every customer app's superset), recorded once it is written.
|
|
140
|
+
* The value is the policy's version: raise it and every stack plans the write again.
|
|
141
|
+
*/
|
|
142
|
+
export const CUSTOMERS_POLICY_SCOPE_KEY = 'zitadel_customers_policy';
|
|
143
|
+
export const CUSTOMERS_POLICY_VERSION = '1';
|
|
131
144
|
/** The ZITADEL org metadata key that holds the left-half text of the customers' sign-in window. */
|
|
132
145
|
export const CUSTOMERS_ROOM_METADATA_KEY = 'wtfalch.room';
|
|
133
146
|
/** The metadata value last written to the customers organisation, or empty after a removal. Absent: never written. */
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { CoolifyPort, ZitadelPort } from './executor.js';
|
|
3
|
+
/**
|
|
4
|
+
* The sign-in broker's ZITADEL credential for registering apps (auth ADR 0003, "Sign-in the broker makes"):
|
|
5
|
+
* `ZITADEL_REGISTRAR_TOKEN`, a machine user of its own, never the login-client PAT. Without it the broker
|
|
6
|
+
* logs "app sign-in off" and no Boule of that sign-in can register an app that signs people in.
|
|
7
|
+
*
|
|
8
|
+
* The user (`ZitadelPort.ensureRegistrarUser`) lives in the instance's first organisation and holds the
|
|
9
|
+
* project-creator role in the company's sign-in organisation and its customers organisations. Its token is
|
|
10
|
+
* minted once (platform-step rule 1), held at the target's `refs.registrarToken`, written to the broker's env
|
|
11
|
+
* and the broker deployed. A token already on the broker's env that foundry did not make is adopted: it is
|
|
12
|
+
* recorded as applied and left alone. Never a token in an error, a log or a recorded id.
|
|
13
|
+
*/
|
|
14
|
+
/** The broker env that holds the token. */
|
|
15
|
+
export declare const REGISTRAR_TOKEN_ENV = "ZITADEL_REGISTRAR_TOKEN";
|
|
16
|
+
/** The machine user's name, the same for every instance. */
|
|
17
|
+
export declare const REGISTRAR_TOKEN_USER_NAME = "foundry-broker-registrar";
|
|
18
|
+
/** The user id of a mint whose token was not yet confirmed delivered (no `:`, so it is a rule 1 guard). Ignored once applied. */
|
|
19
|
+
export declare const REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY = "auth_registrar_token_undelivered";
|
|
20
|
+
/** `<user id>:<pat id>` once the token is held and on the broker; `adopted` for a value foundry did not make. Never the token. */
|
|
21
|
+
export declare const REGISTRAR_TOKEN_SCOPE_KEY = "auth_registrar_token_applied";
|
|
22
|
+
/** {@link registrarOrgsMarker} of the organisations the user was last granted the role in. */
|
|
23
|
+
export declare const REGISTRAR_TOKEN_ORGS_SCOPE_KEY = "auth_registrar_token_orgs";
|
|
24
|
+
/** The adopted marker's value. */
|
|
25
|
+
export declare const REGISTRAR_TOKEN_ADOPTED = "adopted";
|
|
26
|
+
/** A stop whose text names ids only, never a cause, so a caller may log it as it is. */
|
|
27
|
+
export declare class RegistrarTokenError extends Error {
|
|
28
|
+
}
|
|
29
|
+
/** The organisations an own company's registrar user covers besides the instance's first: its sign-in organisation and its customers organisations. */
|
|
30
|
+
export declare function registrarOrgIdsOf(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): string[];
|
|
31
|
+
/** What the orgs marker holds: the ids, sorted, so a reorder is no change. */
|
|
32
|
+
export declare const registrarOrgsMarker: (orgIds: readonly string[]) => string;
|
|
33
|
+
/**
|
|
34
|
+
* Whether the company's broker still needs this step for its token: the token is wanted (customers or apps),
|
|
35
|
+
* and it is not applied or the organisations it covers are not the ones recorded now.
|
|
36
|
+
*/
|
|
37
|
+
export declare function registrarTokenDue(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): boolean;
|
|
38
|
+
interface TokenHeldKeys {
|
|
39
|
+
resolve(ref: string): Promise<string | null | undefined>;
|
|
40
|
+
put?(ref: string, value: string): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
export interface EnsureRegistrarTokenInput {
|
|
43
|
+
/** Names the instance in an error, e.g. `candorfx:auth`. */
|
|
44
|
+
label: string;
|
|
45
|
+
zitadel: Partial<Pick<ZitadelPort, 'ensureRegistrarUser' | 'mintRegistrarToken'>>;
|
|
46
|
+
coolify: Pick<CoolifyPort, 'listEnvs' | 'bulkUpsertEnvs' | 'deploy'>;
|
|
47
|
+
brokerAppId: string;
|
|
48
|
+
/** The instance's first organisation: where the user lives. */
|
|
49
|
+
homeOrgId: string;
|
|
50
|
+
/** The company's organisations the user covers besides the home one. */
|
|
51
|
+
orgIds: readonly string[];
|
|
52
|
+
heldKeys: TokenHeldKeys | undefined;
|
|
53
|
+
ref: string;
|
|
54
|
+
recorded: () => Readonly<Record<string, string>>;
|
|
55
|
+
record: (key: string, value: string) => Promise<void>;
|
|
56
|
+
persist: () => Promise<void>;
|
|
57
|
+
/** Told every secret this function handles, so the caller can redact it. */
|
|
58
|
+
secret: (value: string) => void;
|
|
59
|
+
sleep: (ms: number) => Promise<void>;
|
|
60
|
+
log: (line: string) => void;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Makes the broker hold a registrar token that covers the company's organisations. Resumable: a token held
|
|
64
|
+
* but not delivered is delivered, never minted again; a token minted whose hold did not finish stops with
|
|
65
|
+
* the ids named and nothing minted (a person removes {@link REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY} to make
|
|
66
|
+
* another). Returns what it did.
|
|
67
|
+
*/
|
|
68
|
+
export declare function ensureBrokerRegistrarToken(input: EnsureRegistrarTokenInput): Promise<'adopted' | 'delivered' | 'minted' | 'grants' | 'unchanged'>;
|
|
69
|
+
export {};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
|
|
2
|
+
import { customersOrgIdOf, registrarWanted } from './boule.js';
|
|
3
|
+
import { withTimeout } from './call-timeout.js';
|
|
4
|
+
import { testCustomersOrgKey } from './workspace-client.js';
|
|
5
|
+
/**
|
|
6
|
+
* The sign-in broker's ZITADEL credential for registering apps (auth ADR 0003, "Sign-in the broker makes"):
|
|
7
|
+
* `ZITADEL_REGISTRAR_TOKEN`, a machine user of its own, never the login-client PAT. Without it the broker
|
|
8
|
+
* logs "app sign-in off" and no Boule of that sign-in can register an app that signs people in.
|
|
9
|
+
*
|
|
10
|
+
* The user (`ZitadelPort.ensureRegistrarUser`) lives in the instance's first organisation and holds the
|
|
11
|
+
* project-creator role in the company's sign-in organisation and its customers organisations. Its token is
|
|
12
|
+
* minted once (platform-step rule 1), held at the target's `refs.registrarToken`, written to the broker's env
|
|
13
|
+
* and the broker deployed. A token already on the broker's env that foundry did not make is adopted: it is
|
|
14
|
+
* recorded as applied and left alone. Never a token in an error, a log or a recorded id.
|
|
15
|
+
*/
|
|
16
|
+
/** The broker env that holds the token. */
|
|
17
|
+
export const REGISTRAR_TOKEN_ENV = 'ZITADEL_REGISTRAR_TOKEN';
|
|
18
|
+
/** The machine user's name, the same for every instance. */
|
|
19
|
+
export const REGISTRAR_TOKEN_USER_NAME = 'foundry-broker-registrar';
|
|
20
|
+
/** The user id of a mint whose token was not yet confirmed delivered (no `:`, so it is a rule 1 guard). Ignored once applied. */
|
|
21
|
+
export const REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY = 'auth_registrar_token_undelivered';
|
|
22
|
+
/** `<user id>:<pat id>` once the token is held and on the broker; `adopted` for a value foundry did not make. Never the token. */
|
|
23
|
+
export const REGISTRAR_TOKEN_SCOPE_KEY = 'auth_registrar_token_applied';
|
|
24
|
+
/** {@link registrarOrgsMarker} of the organisations the user was last granted the role in. */
|
|
25
|
+
export const REGISTRAR_TOKEN_ORGS_SCOPE_KEY = 'auth_registrar_token_orgs';
|
|
26
|
+
/** The adopted marker's value. */
|
|
27
|
+
export const REGISTRAR_TOKEN_ADOPTED = 'adopted';
|
|
28
|
+
/** A stop whose text names ids only, never a cause, so a caller may log it as it is. */
|
|
29
|
+
export class RegistrarTokenError extends Error {
|
|
30
|
+
}
|
|
31
|
+
/** The held key store's read and write, and each ZITADEL call, stop at these: a hung call ends the step with a plain line. */
|
|
32
|
+
const HELD_KEY_TIMEOUT_MS = 15_000;
|
|
33
|
+
const ZITADEL_CALL_TIMEOUT_MS = 30_000;
|
|
34
|
+
const COOLIFY_CALL_TIMEOUT_MS = 60_000;
|
|
35
|
+
/** How many times the hold and the delivery are tried. */
|
|
36
|
+
const DELIVER_ATTEMPTS = 3;
|
|
37
|
+
/** The organisations an own company's registrar user covers besides the instance's first: its sign-in organisation and its customers organisations. */
|
|
38
|
+
export function registrarOrgIdsOf(org, recorded) {
|
|
39
|
+
const customers = org.customers?.enabled === true ? customersOrgIdOf(org, recorded) : undefined;
|
|
40
|
+
const test = org.customers?.enabled === true ? recorded[testCustomersOrgKey(org.id)] : undefined;
|
|
41
|
+
return [recorded[ZITADEL_ORG_SCOPE_KEY], customers, test].filter((id) => Boolean(id));
|
|
42
|
+
}
|
|
43
|
+
/** What the orgs marker holds: the ids, sorted, so a reorder is no change. */
|
|
44
|
+
export const registrarOrgsMarker = (orgIds) => [...new Set(orgIds)].sort().join(',');
|
|
45
|
+
/**
|
|
46
|
+
* Whether the company's broker still needs this step for its token: the token is wanted (customers or apps),
|
|
47
|
+
* and it is not applied or the organisations it covers are not the ones recorded now.
|
|
48
|
+
*/
|
|
49
|
+
export function registrarTokenDue(org, recorded) {
|
|
50
|
+
if (!registrarWanted(org))
|
|
51
|
+
return false;
|
|
52
|
+
const applied = recorded[REGISTRAR_TOKEN_SCOPE_KEY];
|
|
53
|
+
if (!applied)
|
|
54
|
+
return true;
|
|
55
|
+
if (applied === REGISTRAR_TOKEN_ADOPTED)
|
|
56
|
+
return false;
|
|
57
|
+
return ((recorded[REGISTRAR_TOKEN_ORGS_SCOPE_KEY] ?? '') !==
|
|
58
|
+
registrarOrgsMarker(registrarOrgIdsOf(org, recorded)));
|
|
59
|
+
}
|
|
60
|
+
/** The same input with a timeout on every held key, ZITADEL and Coolify call. */
|
|
61
|
+
function timed(c) {
|
|
62
|
+
const { heldKeys, zitadel, coolify } = c;
|
|
63
|
+
const { ensureRegistrarUser, mintRegistrarToken } = zitadel;
|
|
64
|
+
return {
|
|
65
|
+
...c,
|
|
66
|
+
coolify: {
|
|
67
|
+
listEnvs: (...args) => withTimeout('Coolify list env', COOLIFY_CALL_TIMEOUT_MS, () => coolify.listEnvs(...args)),
|
|
68
|
+
bulkUpsertEnvs: (...args) => withTimeout('Coolify write env', COOLIFY_CALL_TIMEOUT_MS, () => coolify.bulkUpsertEnvs(...args)),
|
|
69
|
+
deploy: (...args) => withTimeout('Coolify deploy', COOLIFY_CALL_TIMEOUT_MS, () => coolify.deploy(...args)),
|
|
70
|
+
},
|
|
71
|
+
heldKeys: heldKeys && {
|
|
72
|
+
resolve: (ref) => withTimeout('held key read', HELD_KEY_TIMEOUT_MS, () => heldKeys.resolve(ref)),
|
|
73
|
+
...(heldKeys.put
|
|
74
|
+
? {
|
|
75
|
+
put: (ref, value) => withTimeout('held key write', HELD_KEY_TIMEOUT_MS, () => heldKeys.put(ref, value)),
|
|
76
|
+
}
|
|
77
|
+
: {}),
|
|
78
|
+
},
|
|
79
|
+
zitadel: {
|
|
80
|
+
...(ensureRegistrarUser
|
|
81
|
+
? {
|
|
82
|
+
ensureRegistrarUser: (...args) => withTimeout('ZITADEL ensure registrar user', ZITADEL_CALL_TIMEOUT_MS, () => ensureRegistrarUser(...args)),
|
|
83
|
+
}
|
|
84
|
+
: {}),
|
|
85
|
+
...(mintRegistrarToken
|
|
86
|
+
? {
|
|
87
|
+
mintRegistrarToken: (...args) => withTimeout('ZITADEL mint registrar token', ZITADEL_CALL_TIMEOUT_MS, () => mintRegistrarToken(...args)),
|
|
88
|
+
}
|
|
89
|
+
: {}),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Runs one try at a time, up to {@link DELIVER_ATTEMPTS}; the cause of the last failure is dropped. */
|
|
94
|
+
async function retried(sleep, action) {
|
|
95
|
+
for (let attempt = 1; attempt <= DELIVER_ATTEMPTS; attempt += 1) {
|
|
96
|
+
try {
|
|
97
|
+
await action();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// The cause can hold the token (a Coolify validation error echoes the value), so it is dropped.
|
|
102
|
+
if (attempt < DELIVER_ATTEMPTS)
|
|
103
|
+
await sleep(1000 * attempt);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Makes the broker hold a registrar token that covers the company's organisations. Resumable: a token held
|
|
110
|
+
* but not delivered is delivered, never minted again; a token minted whose hold did not finish stops with
|
|
111
|
+
* the ids named and nothing minted (a person removes {@link REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY} to make
|
|
112
|
+
* another). Returns what it did.
|
|
113
|
+
*/
|
|
114
|
+
export async function ensureBrokerRegistrarToken(input) {
|
|
115
|
+
const c = timed(input);
|
|
116
|
+
const covered = [c.homeOrgId, ...c.orgIds];
|
|
117
|
+
const wanted = registrarOrgsMarker(c.orgIds);
|
|
118
|
+
const envRows = await c.coolify.listEnvs(c.brokerAppId);
|
|
119
|
+
const live = envRows.find((e) => e.key === REGISTRAR_TOKEN_ENV && !e.is_preview)?.value;
|
|
120
|
+
if (live)
|
|
121
|
+
c.secret(live);
|
|
122
|
+
const before = c.recorded();
|
|
123
|
+
const applied = before[REGISTRAR_TOKEN_SCOPE_KEY];
|
|
124
|
+
if (applied) {
|
|
125
|
+
if (!live) {
|
|
126
|
+
// The env was lost: the held token is delivered again; with none held, nothing is made over it.
|
|
127
|
+
const held = await c.heldKeys?.resolve(c.ref);
|
|
128
|
+
if (!held) {
|
|
129
|
+
throw new RegistrarTokenError(`${c.label}: the broker has no ${REGISTRAR_TOKEN_ENV} and the registrar token is not held (ref ${c.ref}); remove ${REGISTRAR_TOKEN_SCOPE_KEY} to make a new one`);
|
|
130
|
+
}
|
|
131
|
+
c.secret(held);
|
|
132
|
+
await deliver(c, held);
|
|
133
|
+
return 'delivered';
|
|
134
|
+
}
|
|
135
|
+
if (applied !== REGISTRAR_TOKEN_ADOPTED && before[REGISTRAR_TOKEN_ORGS_SCOPE_KEY] !== wanted) {
|
|
136
|
+
await grant(c, covered);
|
|
137
|
+
await c.record(REGISTRAR_TOKEN_ORGS_SCOPE_KEY, wanted);
|
|
138
|
+
await c.persist();
|
|
139
|
+
return 'grants';
|
|
140
|
+
}
|
|
141
|
+
return 'unchanged';
|
|
142
|
+
}
|
|
143
|
+
const undelivered = before[REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY];
|
|
144
|
+
const held = await c.heldKeys?.resolve(c.ref);
|
|
145
|
+
if (live && !undelivered && held !== live) {
|
|
146
|
+
// A value somebody set by hand (Candor FX's): kept as it is. Its user is not known, so no grants.
|
|
147
|
+
c.log(`${c.label}: the broker already has a ${REGISTRAR_TOKEN_ENV} foundry did not make; adopted, not replaced`);
|
|
148
|
+
await c.record(REGISTRAR_TOKEN_SCOPE_KEY, REGISTRAR_TOKEN_ADOPTED);
|
|
149
|
+
await c.persist();
|
|
150
|
+
return 'adopted';
|
|
151
|
+
}
|
|
152
|
+
if (undelivered && !held) {
|
|
153
|
+
throw new RegistrarTokenError(`${c.label}: the registrar token of ZITADEL machine user ${undelivered} was minted but never confirmed held; remove the recorded key ${REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY} to make a new one`);
|
|
154
|
+
}
|
|
155
|
+
if (held) {
|
|
156
|
+
// Made by a run that stopped after holding it: delivered now, nothing minted.
|
|
157
|
+
c.secret(held);
|
|
158
|
+
await grant(c, covered);
|
|
159
|
+
await c.record(REGISTRAR_TOKEN_ORGS_SCOPE_KEY, wanted);
|
|
160
|
+
await deliver(c, held);
|
|
161
|
+
await c.record(REGISTRAR_TOKEN_SCOPE_KEY, undelivered ? `${undelivered}:held` : 'held');
|
|
162
|
+
await c.persist();
|
|
163
|
+
return 'delivered';
|
|
164
|
+
}
|
|
165
|
+
if (typeof c.heldKeys?.put !== 'function') {
|
|
166
|
+
throw new RegistrarTokenError(`${c.label}: the held keys cannot hold the registrar token (ref ${c.ref})`);
|
|
167
|
+
}
|
|
168
|
+
if (!c.zitadel.ensureRegistrarUser || !c.zitadel.mintRegistrarToken) {
|
|
169
|
+
throw new RegistrarTokenError(`${c.label}: the host's ZITADEL port cannot make a registrar token`);
|
|
170
|
+
}
|
|
171
|
+
const { userId } = await c.zitadel.ensureRegistrarUser(covered, {
|
|
172
|
+
userName: REGISTRAR_TOKEN_USER_NAME,
|
|
173
|
+
name: 'Foundry (sign-in broker registrar)',
|
|
174
|
+
});
|
|
175
|
+
const { token, patId } = await c.zitadel.mintRegistrarToken(c.homeOrgId, userId);
|
|
176
|
+
c.secret(token);
|
|
177
|
+
// Recorded and persisted before the token goes anywhere: a crash from here on never mints a second one.
|
|
178
|
+
await c.record(REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY, userId);
|
|
179
|
+
await c.persist();
|
|
180
|
+
const put = c.heldKeys.put.bind(c.heldKeys);
|
|
181
|
+
if (!(await retried(c.sleep, () => put(c.ref, token)))) {
|
|
182
|
+
throw new RegistrarTokenError(`${c.label}: the registrar token of ZITADEL machine user ${userId} was minted but could not be held after ${DELIVER_ATTEMPTS} tries (ref ${c.ref})`);
|
|
183
|
+
}
|
|
184
|
+
await deliver(c, token, userId);
|
|
185
|
+
await c.record(REGISTRAR_TOKEN_ORGS_SCOPE_KEY, wanted);
|
|
186
|
+
// Last: a run that failed above plans the step again.
|
|
187
|
+
await c.record(REGISTRAR_TOKEN_SCOPE_KEY, `${userId}:${patId}`);
|
|
188
|
+
await c.persist();
|
|
189
|
+
return 'minted';
|
|
190
|
+
}
|
|
191
|
+
async function grant(c, covered) {
|
|
192
|
+
if (!c.zitadel.ensureRegistrarUser) {
|
|
193
|
+
throw new RegistrarTokenError(`${c.label}: the host's ZITADEL port cannot grant the registrar user`);
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
await c.zitadel.ensureRegistrarUser(covered, {
|
|
197
|
+
userName: REGISTRAR_TOKEN_USER_NAME,
|
|
198
|
+
name: 'Foundry (sign-in broker registrar)',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
throw new RegistrarTokenError(`${c.label}: the registrar user could not be granted its organisations; apply again`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** Writes the token to the broker's env and deploys the broker, each retried; the cause is dropped. */
|
|
206
|
+
async function deliver(c, token, userId) {
|
|
207
|
+
const who = userId ? ` of ZITADEL machine user ${userId}` : '';
|
|
208
|
+
const wrote = await retried(c.sleep, () => c.coolify.bulkUpsertEnvs(c.brokerAppId, [{ key: REGISTRAR_TOKEN_ENV, value: token }]));
|
|
209
|
+
// Forced: an unforced deploy answers "skipped" when one for the same commit is queued, which would let
|
|
210
|
+
// this record delivery while the running broker never sees the token.
|
|
211
|
+
const deployed = wrote && (await retried(c.sleep, () => c.coolify.deploy(c.brokerAppId, true)));
|
|
212
|
+
if (!deployed) {
|
|
213
|
+
throw new RegistrarTokenError(`${c.label}: the registrar token${who} is held but could not be delivered to the broker after ${DELIVER_ATTEMPTS} tries; the next apply delivers it again`);
|
|
214
|
+
}
|
|
215
|
+
}
|