@wtfalch/foundry 0.8.0 → 0.9.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/bootstrap/declare.js +1 -5
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/migrations/0029_hosting_entries.sql +109 -0
- package/dist/org.d.ts +59 -27
- package/dist/org.js +98 -26
- package/dist/plan/diff.js +25 -19
- package/dist/plan/executor.d.ts +45 -29
- package/dist/plan/executor.js +291 -158
- package/dist/plan/index.d.ts +1 -1
- package/dist/plan/index.js +1 -1
- package/dist/plan/plan-apply.js +6 -9
- package/dist/plan/platform-diff.d.ts +23 -11
- package/dist/plan/platform-diff.js +39 -14
- package/dist/plan/platform-providers.d.ts +7 -0
- package/dist/plan/platform-providers.js +93 -13
- package/dist/plan/zitadel-system-api.d.ts +64 -0
- package/dist/plan/zitadel-system-api.js +125 -0
- package/dist/platform.d.ts +1 -1
- package/dist/store/postgres.js +2 -10
- package/dist/store/schema.d.ts +20 -1
- package/dist/store/schema.js +7 -1
- package/package.json +1 -1
package/dist/plan/index.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, Confirmatio
|
|
|
13
13
|
export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './platform-providers.js';
|
|
14
14
|
export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, } from './platform-providers.js';
|
|
15
15
|
export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './platform-providers.js';
|
|
16
|
-
export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, } from './platform-providers.js';
|
|
16
|
+
export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
|
|
17
17
|
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';
|
|
18
18
|
export type { AiManagementKeyMinter, PlatformAiDeps, PlatformAiRuntime, } from './platform-providers.js';
|
|
19
19
|
export { createZitadelAdminMinter } from './zitadel-machine-user.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -6,7 +6,7 @@ export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.
|
|
|
6
6
|
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
|
|
7
7
|
export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './platform-providers.js';
|
|
8
8
|
// #171: how a host signs files management requests with the delivered key.
|
|
9
|
-
export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, } from './platform-providers.js';
|
|
9
|
+
export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
|
|
10
10
|
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';
|
|
11
11
|
export { createZitadelAdminMinter } from './zitadel-machine-user.js';
|
|
12
12
|
export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -633,13 +633,12 @@ function stepField(action) {
|
|
|
633
633
|
*
|
|
634
634
|
* Three sources, in this order:
|
|
635
635
|
* - fields no planner step exists for yet, so no apply can make them true:
|
|
636
|
-
* SSO, guests and solo when turned on,
|
|
637
|
-
* (#
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
* reads it (#240), so it converges with that step like any other field;
|
|
636
|
+
* SSO, guests and solo when turned on, and calls or hosting on a domain
|
|
637
|
+
* the org has not recorded (#145: calls on the org's own domain now has a
|
|
638
|
+
* planner step, the same way hosting's own domain does, so it only fails
|
|
639
|
+
* for that one missing-domain reason, not unconditionally). An app repo is
|
|
640
|
+
* not one of these: `coolify:app` reads the `app` entry's `repo`, so it
|
|
641
|
+
* converges with that step like any other field;
|
|
643
642
|
* - chat's reverse re-key, which the planner surfaces but which is not built
|
|
644
643
|
* (#7 amendment 3), so it counts as failed even when no step threw;
|
|
645
644
|
* - on a failed apply, the field of every planned step from the one that
|
|
@@ -663,8 +662,6 @@ function failedFields(org, detail, completed, serviceIds) {
|
|
|
663
662
|
failed.add('guests.enabled');
|
|
664
663
|
if (org.solo.enabled)
|
|
665
664
|
failed.add('solo.enabled');
|
|
666
|
-
if (org.adoptedApp?.form === 'repo')
|
|
667
|
-
failed.add('adoptedApp');
|
|
668
665
|
// `planCallsService`/`planTunnel`/`planZone` all share this same domain
|
|
669
666
|
// requirement (#145): a declared `usesDomain: true` with no recorded
|
|
670
667
|
// domain plans no step for any of them either.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DeclaredOrg, OrgId } from '../org.js';
|
|
1
|
+
import type { DeclaredOrg, HostingEntry, OrgId } from '../org.js';
|
|
2
2
|
import { type Plan, type UnmanagedItem, type VersionChangeItem } from '../plan.js';
|
|
3
3
|
import type { DeclaredPlatform } from '../platform.js';
|
|
4
4
|
import type { PlannedStep } from './diff.js';
|
|
@@ -118,6 +118,10 @@ export declare const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = "archon_applied_
|
|
|
118
118
|
export declare const PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY = "archon_gate_closed";
|
|
119
119
|
export declare const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = "zitadel_platform_admin_user_id";
|
|
120
120
|
export declare const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = "zitadel_platform_admin_user_undelivered";
|
|
121
|
+
/** #283: the created instance's id, recorded once its owner token is delivered. */
|
|
122
|
+
export declare const PLATFORM_AUTH_INSTANCE_SCOPE_KEY = "zitadel_platform_instance_id";
|
|
123
|
+
/** #283: an instance that may exist without a delivered owner token; blocks creating another. */
|
|
124
|
+
export declare const PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY = "zitadel_platform_instance_undelivered";
|
|
121
125
|
/**
|
|
122
126
|
* #216 (building on #209/#215): the `IAM_LOGIN_CLIENT` machine user minted
|
|
123
127
|
* for the login app and the broker, mirroring
|
|
@@ -312,24 +316,32 @@ export declare function platformMailMxHost(apex: string): string;
|
|
|
312
316
|
export declare const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = "mail_applied_apex";
|
|
313
317
|
export declare const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = "mail_applied_host";
|
|
314
318
|
/**
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
* from it, so a changed map reaches an app that is already live.
|
|
319
|
+
* The key of an org's `app` hosting entry in a `coolify:app` or
|
|
320
|
+
* `cloudflare:access` step and in the ids recorded for it:
|
|
321
|
+
* `<orgId>:<entry key>`, so a second entry cannot collide with the first.
|
|
319
322
|
*/
|
|
320
|
-
export declare
|
|
323
|
+
export declare function appStepKey(org: DeclaredOrg): string;
|
|
324
|
+
/**
|
|
325
|
+
* #243: the org's `app` entry's `env` values the last `coolify:app` apply
|
|
326
|
+
* pushed, recorded as one JSON object (env name to value; ids and a domain,
|
|
327
|
+
* never a secret). `planApp` plans the step again when {@link resolveAppEnv}
|
|
328
|
+
* differs from it, so a changed map reaches an app that is already live. The
|
|
329
|
+
* `.app` suffix is the entry key; a scope key holds no `:`, so
|
|
330
|
+
* `storeRecordedIds` still filters it out.
|
|
331
|
+
*/
|
|
332
|
+
export declare const APP_ENV_APPLIED_SCOPE_KEY = "app_env_applied.app";
|
|
321
333
|
/**
|
|
322
334
|
* #239: set to '1' once an app has been restarted after its database URLs
|
|
323
335
|
* were pushed. Not a URL. Without it a crash between the push and the restart
|
|
324
336
|
* would leave a stale container that no plan notices.
|
|
325
337
|
*/
|
|
326
|
-
export declare const DB_ENV_APPLIED_SCOPE_KEY = "db_env_applied";
|
|
338
|
+
export declare const DB_ENV_APPLIED_SCOPE_KEY = "db_env_applied.app";
|
|
327
339
|
/**
|
|
328
|
-
* The `
|
|
329
|
-
*
|
|
330
|
-
*
|
|
340
|
+
* The entry's `env` values that have a value, in env-name order, and the env
|
|
341
|
+
* names whose source has none yet. Shared by `planApp` and the executor's
|
|
342
|
+
* `pushAppEnv` so both read the same recorded ids.
|
|
331
343
|
*/
|
|
332
|
-
export declare function resolveAppEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): {
|
|
344
|
+
export declare function resolveAppEnv(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>): {
|
|
333
345
|
values: Record<string, string>;
|
|
334
346
|
unresolved: Array<[string, string]>;
|
|
335
347
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { VERSIONS } from '../bootstrap/versions.js';
|
|
2
|
+
import { APP_ENTRY_KEY } from '../org.js';
|
|
2
3
|
import { VERSION_CHANGE_KIND, } from '../plan.js';
|
|
3
4
|
/**
|
|
4
5
|
* The `Plan` (#19's public contract) a {@link PlatformPlanDetail} carries,
|
|
@@ -136,6 +137,10 @@ export const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = 'archon_applied_broker_a
|
|
|
136
137
|
export const PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY = 'archon_gate_closed';
|
|
137
138
|
export const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = 'zitadel_platform_admin_user_id';
|
|
138
139
|
export const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_admin_user_undelivered';
|
|
140
|
+
/** #283: the created instance's id, recorded once its owner token is delivered. */
|
|
141
|
+
export const PLATFORM_AUTH_INSTANCE_SCOPE_KEY = 'zitadel_platform_instance_id';
|
|
142
|
+
/** #283: an instance that may exist without a delivered owner token; blocks creating another. */
|
|
143
|
+
export const PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_instance_undelivered';
|
|
139
144
|
/**
|
|
140
145
|
* #216 (building on #209/#215): the `IAM_LOGIN_CLIENT` machine user minted
|
|
141
146
|
* for the login app and the broker, mirroring
|
|
@@ -343,24 +348,34 @@ export function platformMailMxHost(apex) {
|
|
|
343
348
|
export const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = 'mail_applied_apex';
|
|
344
349
|
export const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = 'mail_applied_host';
|
|
345
350
|
/**
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
* from it, so a changed map reaches an app that is already live.
|
|
351
|
+
* The key of an org's `app` hosting entry in a `coolify:app` or
|
|
352
|
+
* `cloudflare:access` step and in the ids recorded for it:
|
|
353
|
+
* `<orgId>:<entry key>`, so a second entry cannot collide with the first.
|
|
350
354
|
*/
|
|
351
|
-
export
|
|
355
|
+
export function appStepKey(org) {
|
|
356
|
+
return `${org.id}:${APP_ENTRY_KEY}`;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* #243: the org's `app` entry's `env` values the last `coolify:app` apply
|
|
360
|
+
* pushed, recorded as one JSON object (env name to value; ids and a domain,
|
|
361
|
+
* never a secret). `planApp` plans the step again when {@link resolveAppEnv}
|
|
362
|
+
* differs from it, so a changed map reaches an app that is already live. The
|
|
363
|
+
* `.app` suffix is the entry key; a scope key holds no `:`, so
|
|
364
|
+
* `storeRecordedIds` still filters it out.
|
|
365
|
+
*/
|
|
366
|
+
export const APP_ENV_APPLIED_SCOPE_KEY = `app_env_applied.${APP_ENTRY_KEY}`;
|
|
352
367
|
/**
|
|
353
368
|
* #239: set to '1' once an app has been restarted after its database URLs
|
|
354
369
|
* were pushed. Not a URL. Without it a crash between the push and the restart
|
|
355
370
|
* would leave a stale container that no plan notices.
|
|
356
371
|
*/
|
|
357
|
-
export const DB_ENV_APPLIED_SCOPE_KEY =
|
|
372
|
+
export const DB_ENV_APPLIED_SCOPE_KEY = `db_env_applied.${APP_ENTRY_KEY}`;
|
|
358
373
|
/**
|
|
359
|
-
* The `
|
|
360
|
-
*
|
|
361
|
-
*
|
|
374
|
+
* The entry's `env` values that have a value, in env-name order, and the env
|
|
375
|
+
* names whose source has none yet. Shared by `planApp` and the executor's
|
|
376
|
+
* `pushAppEnv` so both read the same recorded ids.
|
|
362
377
|
*/
|
|
363
|
-
export function resolveAppEnv(org, recorded) {
|
|
378
|
+
export function resolveAppEnv(org, entry, recorded) {
|
|
364
379
|
const sources = {
|
|
365
380
|
filesOrganisationId: recorded[`files:organisation:${org.id}`],
|
|
366
381
|
aiOrganisationId: recorded[`ai:organisation:${org.id}`],
|
|
@@ -368,12 +383,12 @@ export function resolveAppEnv(org, recorded) {
|
|
|
368
383
|
};
|
|
369
384
|
const values = {};
|
|
370
385
|
const unresolved = [];
|
|
371
|
-
for (const [name,
|
|
372
|
-
const value = sources[
|
|
386
|
+
for (const [name, declared] of Object.entries(entry.env).sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
387
|
+
const value = typeof declared === 'string' ? declared : sources[declared.from];
|
|
373
388
|
if (value)
|
|
374
389
|
values[name] = value;
|
|
375
|
-
else
|
|
376
|
-
unresolved.push([name,
|
|
390
|
+
else if (typeof declared !== 'string')
|
|
391
|
+
unresolved.push([name, declared.from]);
|
|
377
392
|
}
|
|
378
393
|
return { values, unresolved };
|
|
379
394
|
}
|
|
@@ -999,6 +1014,16 @@ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authD
|
|
|
999
1014
|
blocked: `the platform's sign-in is blocked, not planned: the admin key of ZITADEL machine user ${undelivered} was minted but never delivered and cannot be shown again; delete that user in ZITADEL and remove the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`,
|
|
1000
1015
|
};
|
|
1001
1016
|
}
|
|
1017
|
+
// #283: an instance may exist whose owner token never reached the host.
|
|
1018
|
+
const instanceLost = recordedIds[PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY];
|
|
1019
|
+
if (instanceLost &&
|
|
1020
|
+
!recordedIds[PLATFORM_AUTH_INSTANCE_SCOPE_KEY] &&
|
|
1021
|
+
!recordedIds[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
|
|
1022
|
+
return {
|
|
1023
|
+
step: null,
|
|
1024
|
+
blocked: `the platform's sign-in is blocked, not planned: a ZITADEL instance (${instanceLost}) may exist without a delivered owner token; delete it with the system API (DELETE /system/v1/instances/{id}) or redeploy the ZITADEL database, then remove the recorded key ${PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY}`,
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1002
1027
|
// #211 round 6: the executor itself is unusable on this host -- nothing to
|
|
1003
1028
|
// plan, whatever the broker's recorded state says.
|
|
1004
1029
|
if (executorMissing)
|
|
@@ -223,6 +223,13 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
|
|
|
223
223
|
private executeSfu;
|
|
224
224
|
private coolifyJobs;
|
|
225
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* #283: the ref the new instance's IAM_OWNER machine token (a PAT) is
|
|
228
|
+
* delivered under, once. A host reads it back to build `zitadelForInstance`
|
|
229
|
+
* (see {@link provisionPlatformAuth}'s doc comment). Distinct from the admin
|
|
230
|
+
* key delivered under `zitadel:platform:admin`.
|
|
231
|
+
*/
|
|
232
|
+
export declare const PLATFORM_AUTH_INSTANCE_BEARER_REF = "zitadel:platform:instanceBearer";
|
|
226
233
|
/**
|
|
227
234
|
* The fixed description Stalwart's admin API key is minted under
|
|
228
235
|
* (`StalwartClient.createApiKey`'s own signature returns only the secret,
|
|
@@ -4,8 +4,9 @@ import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
|
4
4
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
5
5
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
6
6
|
import { ACCESS_POLICY_NAME, isNotFoundError, isRunningOrStarting } from './executor.js';
|
|
7
|
-
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
|
|
7
|
+
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
|
|
8
8
|
import { applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
9
|
+
import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
|
|
9
10
|
/** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
|
|
10
11
|
function apexZoneKey(apex) {
|
|
11
12
|
return `cloudflare:zone:${apex}`;
|
|
@@ -495,7 +496,7 @@ const AUTH_BROKER_PORT = '3000';
|
|
|
495
496
|
* falls through to the login app's own ingress rule below (login v2 answers
|
|
496
497
|
* everything else, including the root).
|
|
497
498
|
*/
|
|
498
|
-
const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|system|auth|assets|ui/console|\\.well-known)(/|$)';
|
|
499
|
+
const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|system|auth|assets|debug/healthz|ui/console|\\.well-known)(/|$)';
|
|
499
500
|
/**
|
|
500
501
|
* `@wtfalch/auth`'s own `brokerUrl` default, `<issuer>/api`
|
|
501
502
|
* (`packages/auth/src/config.ts`) -- `deploy/proxy/Caddyfile`'s own
|
|
@@ -515,6 +516,14 @@ const PLATFORM_AUTH_ADMIN_USER_NAME = 'foundry-admin';
|
|
|
515
516
|
/** #216 (building on #209/#215): the narrower `IAM_LOGIN_CLIENT` machine user for the login app and the broker. */
|
|
516
517
|
const PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME = 'foundry-login-client';
|
|
517
518
|
const PLATFORM_AUTH_ORG_NAME = 'foundry';
|
|
519
|
+
/**
|
|
520
|
+
* #283: the ref the new instance's IAM_OWNER machine token (a PAT) is
|
|
521
|
+
* delivered under, once. A host reads it back to build `zitadelForInstance`
|
|
522
|
+
* (see {@link provisionPlatformAuth}'s doc comment). Distinct from the admin
|
|
523
|
+
* key delivered under `zitadel:platform:admin`.
|
|
524
|
+
*/
|
|
525
|
+
export const PLATFORM_AUTH_INSTANCE_BEARER_REF = 'zitadel:platform:instanceBearer';
|
|
526
|
+
const PLATFORM_AUTH_BEARER_USER_NAME = 'foundry-instance-owner';
|
|
518
527
|
/**
|
|
519
528
|
* Retries `action` up to `attempts` times, sleeping increasingly between
|
|
520
529
|
* tries -- the same shape the admin machine user's own delivery retry (just
|
|
@@ -758,8 +767,24 @@ async function liveLoginClientTokenHash(coolify, appId) {
|
|
|
758
767
|
* first), so it will not start (`parseApps` throws on an empty list) until
|
|
759
768
|
* then either. Safe to run again: the next apply that registers an app
|
|
760
769
|
* redeploys the broker with a non-empty list.
|
|
761
|
-
* - Whoever supplies `deps.zitadel` (or `zitadelForInstance`) must hold a
|
|
762
|
-
* bearer token for the new instance, which foundry cannot read from it
|
|
770
|
+
* - ~~Whoever supplies `deps.zitadel` (or `zitadelForInstance`) must hold a
|
|
771
|
+
* bearer token for the new instance, which foundry cannot read from it.~~
|
|
772
|
+
* Closed by #283: see "The instance bearer" below.
|
|
773
|
+
*
|
|
774
|
+
* The instance bearer (#283): the container creates no instance of its own
|
|
775
|
+
* (`ZITADEL_FIRSTINSTANCE_SKIP`). foundry generates an RSA keypair, puts the
|
|
776
|
+
* public half in `ZITADEL_SYSTEMAPIUSERS` (a system API user with
|
|
777
|
+
* SYSTEM_OWNER only), and once the container answers calls ZITADEL's system
|
|
778
|
+
* API `CreateInstance` with a machine owner (IAM_OWNER of the new instance)
|
|
779
|
+
* signed by the private half, which lives in memory for that run only. The
|
|
780
|
+
* owner's PAT comes back once and is handed to
|
|
781
|
+
* {@link PlatformAuthDeps.deliverSecret} under
|
|
782
|
+
* {@link PLATFORM_AUTH_INSTANCE_BEARER_REF}, before anything else uses it. A
|
|
783
|
+
* host builds `zitadelForInstance` as `(url) => createZitadelAdminMinter({
|
|
784
|
+
* url, token: <the secret delivered under that ref> })` plus its own org
|
|
785
|
+
* calls; `zitadelForInstance` is first called after the delivery. No human
|
|
786
|
+
* admin is created: a person who needs console access is added by the host
|
|
787
|
+
* through that bearer.
|
|
763
788
|
*
|
|
764
789
|
* #197: a `cloudflared` connector for this tunnel is run
|
|
765
790
|
* ({@link ensureCloudflaredConnector}), so `hostname` answers.
|
|
@@ -899,7 +924,19 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
899
924
|
// same rule `executor.ts`'s `ensureAuthCookieSecret` follows for a value
|
|
900
925
|
// this package mints itself rather than receiving from a provider.
|
|
901
926
|
const masterkey = await ensureMasterkey(deps.coolify, zitadelAppId, databaseInUse);
|
|
902
|
-
|
|
927
|
+
// #283: the instance is created once, with the system API, by a platform
|
|
928
|
+
// whose admin machine user is not recorded yet (one that already has it was
|
|
929
|
+
// built from a first instance and keeps it). The keypair is regenerated on
|
|
930
|
+
// any run that still has to create it, so the private half never needs to
|
|
931
|
+
// be held anywhere between runs.
|
|
932
|
+
const lostInstance = recorded[PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY];
|
|
933
|
+
if (lostInstance &&
|
|
934
|
+
!recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY] &&
|
|
935
|
+
!recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
|
|
936
|
+
throw new Error(instanceWithoutBearerMessage(hostname, `instance ${lostInstance}`));
|
|
937
|
+
}
|
|
938
|
+
const needsInstance = !recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY] && !recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY];
|
|
939
|
+
const systemKeys = needsInstance ? generateSystemApiKeypair() : undefined;
|
|
903
940
|
const zitadelEnvs = [
|
|
904
941
|
{ key: 'ZITADEL_PORT', value: AUTH_ZITADEL_PORT },
|
|
905
942
|
{ key: 'ZITADEL_EXTERNALDOMAIN', value: hostname },
|
|
@@ -907,8 +944,7 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
907
944
|
{ key: 'ZITADEL_EXTERNALSECURE', value: 'true' },
|
|
908
945
|
{ key: 'ZITADEL_TLS_ENABLED', value: 'false' },
|
|
909
946
|
{ key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: db.internal_db_url },
|
|
910
|
-
{ key: '
|
|
911
|
-
{ key: 'ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED', value: 'false' },
|
|
947
|
+
{ key: 'ZITADEL_FIRSTINSTANCE_SKIP', value: 'true' },
|
|
912
948
|
{ key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED', value: 'true' },
|
|
913
949
|
{ key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI', value: `https://${hostname}/` },
|
|
914
950
|
{ key: 'ZITADEL_OIDC_DEFAULTLOGINURLV2', value: `https://${hostname}/login?authRequest=` },
|
|
@@ -921,8 +957,12 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
921
957
|
];
|
|
922
958
|
if (masterkey)
|
|
923
959
|
zitadelEnvs.push({ key: 'ZITADEL_MASTERKEY', value: masterkey });
|
|
924
|
-
if (
|
|
925
|
-
zitadelEnvs.push({
|
|
960
|
+
if (systemKeys) {
|
|
961
|
+
zitadelEnvs.push({
|
|
962
|
+
key: 'ZITADEL_SYSTEMAPIUSERS',
|
|
963
|
+
value: systemApiUsersEnv(systemKeys.publicKey),
|
|
964
|
+
});
|
|
965
|
+
}
|
|
926
966
|
const loginEnvs = [
|
|
927
967
|
{
|
|
928
968
|
key: 'ZITADEL_API_URL',
|
|
@@ -1051,6 +1091,50 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
1051
1091
|
// a caller that reaches the step anyway.
|
|
1052
1092
|
throw new Error(`platform:auth: the admin key of ZITADEL machine user ${lost} was minted but never delivered; delete that user and the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
1053
1093
|
}
|
|
1094
|
+
if (systemKeys) {
|
|
1095
|
+
// ZITADEL answers /debug/healthz without an instance; the openid
|
|
1096
|
+
// document below only exists once the instance does.
|
|
1097
|
+
await waitForInstance(`https://${hostname}/debug/healthz`, runtime, sleep);
|
|
1098
|
+
// Sent exactly once. Anything that may have reached ZITADEL leaves a
|
|
1099
|
+
// marker first, so the next run refuses instead of creating again.
|
|
1100
|
+
let created;
|
|
1101
|
+
try {
|
|
1102
|
+
created = await createZitadelInstance({
|
|
1103
|
+
customDomain: hostname,
|
|
1104
|
+
privateKey: systemKeys.privateKey,
|
|
1105
|
+
instanceName: PLATFORM_AUTH_ORG_NAME,
|
|
1106
|
+
firstOrgName: PLATFORM_AUTH_ORG_NAME,
|
|
1107
|
+
owner: { userName: PLATFORM_AUTH_BEARER_USER_NAME, name: 'Foundry (instance owner)' },
|
|
1108
|
+
}, { fetch: runtime.fetch, now: runtime.now ?? Date.now });
|
|
1109
|
+
}
|
|
1110
|
+
catch (error) {
|
|
1111
|
+
if (error instanceof CreateInstanceError && error.kind !== 'rejected') {
|
|
1112
|
+
await record(PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, 'unknown');
|
|
1113
|
+
}
|
|
1114
|
+
throw error;
|
|
1115
|
+
}
|
|
1116
|
+
const { instanceId, pat } = created;
|
|
1117
|
+
// Recorded before the PAT leaves this function, like the admin key below.
|
|
1118
|
+
await record(PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, instanceId);
|
|
1119
|
+
const attempts = runtime.deliverAttempts ?? 3;
|
|
1120
|
+
for (let attempt = 1;; attempt += 1) {
|
|
1121
|
+
try {
|
|
1122
|
+
await deps.deliverSecret(PLATFORM_AUTH_INSTANCE_BEARER_REF, pat);
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
catch (error) {
|
|
1126
|
+
if (attempt >= attempts) {
|
|
1127
|
+
const reason = redactSecrets(error instanceof Error ? error.message : String(error), [
|
|
1128
|
+
pat,
|
|
1129
|
+
]);
|
|
1130
|
+
throw new Error(`platform:auth: the owner token of ZITADEL instance ${instanceId} was created but could not be delivered after ${attempts} tries (${reason}); ${instanceWithoutBearerMessage(hostname, `instance ${instanceId}`)}`);
|
|
1131
|
+
}
|
|
1132
|
+
await sleep(1000 * attempt);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
// The marker stays recorded but is ignored once this is set.
|
|
1136
|
+
await record(PLATFORM_AUTH_INSTANCE_SCOPE_KEY, instanceId);
|
|
1137
|
+
}
|
|
1054
1138
|
// Minting needs a running instance behind the tunnel: the first apply
|
|
1055
1139
|
// gets here right after the deploy.
|
|
1056
1140
|
await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep);
|
|
@@ -3642,10 +3726,6 @@ async function ensureMasterkey(coolify, appId, databaseInUse) {
|
|
|
3642
3726
|
}
|
|
3643
3727
|
return randomBytes(24).toString('base64');
|
|
3644
3728
|
}
|
|
3645
|
-
/** `docs/deploy.md`: "ZITADEL_ADMIN_PASSWORD: 8+, upper, lower, digit, symbol". */
|
|
3646
|
-
function generateAdminPassword() {
|
|
3647
|
-
return `${randomBytes(18).toString('base64')}Aa1!`;
|
|
3648
|
-
}
|
|
3649
3729
|
/**
|
|
3650
3730
|
* #171: how the host signs a files management request with the key this step
|
|
3651
3731
|
* delivers under {@link FILES_MANAGEMENT_KEY_REF} (files'
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #283: foundry's way into a brand-new ZITADEL instance. ZITADEL's system API
|
|
3
|
+
* (`POST /system/v1/instances/_create`, permission `system.instance.write`,
|
|
4
|
+
* https://github.com/zitadel/zitadel/blob/main/proto/zitadel/system.proto)
|
|
5
|
+
* creates the instance with a machine owner (IAM_OWNER of that instance) and
|
|
6
|
+
* returns that owner's personal access token (`pat`) once, inline. foundry
|
|
7
|
+
* authenticates to the system API with a short-lived RS256 JWT signed by a
|
|
8
|
+
* keypair it generates: the public half goes into the container as a system
|
|
9
|
+
* API user, the private half stays in memory for one run. The calls follow
|
|
10
|
+
* ZITADEL's source and proto and have not been run against a live instance.
|
|
11
|
+
*/
|
|
12
|
+
/** The system API user's name; it is the JWT's `iss` and `sub`. */
|
|
13
|
+
export declare const SYSTEM_API_USER_NAME = "foundry";
|
|
14
|
+
/** A fresh RSA keypair: the public key (PEM) for ZITADEL, the private key (PKCS#8 PEM) for signing. */
|
|
15
|
+
export declare function generateSystemApiKeypair(): {
|
|
16
|
+
publicKey: string;
|
|
17
|
+
privateKey: string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The value of `ZITADEL_SYSTEMAPIUSERS`: JSON in ZITADEL's `SystemAPIUsers`
|
|
21
|
+
* shape (`cmd/defaults.yaml`), `KeyData` being the base64 of the public key
|
|
22
|
+
* PEM. With no `Memberships` ZITADEL grants exactly System/SYSTEM_OWNER.
|
|
23
|
+
*/
|
|
24
|
+
export declare function systemApiUsersEnv(publicKeyPem: string): string;
|
|
25
|
+
/** A 1-minute RS256 JWT for the system API: `iss` = `sub` = the user name, `aud` = the instance URL. */
|
|
26
|
+
export declare function signSystemApiJwt(privateKeyPem: string, audience: string, nowMs: number): string;
|
|
27
|
+
/**
|
|
28
|
+
* How a create failed. `rejected`: ZITADEL refused it for good before doing
|
|
29
|
+
* anything (bad key, bad request), so nothing exists. `exists`: an instance
|
|
30
|
+
* for the domain already exists. `uncertain`: the request may have reached
|
|
31
|
+
* ZITADEL (timeout, network error, 5xx, an unreadable answer), so an instance
|
|
32
|
+
* may exist. The caller records a marker for the last two.
|
|
33
|
+
*/
|
|
34
|
+
export declare class CreateInstanceError extends Error {
|
|
35
|
+
readonly kind: 'rejected' | 'exists' | 'uncertain';
|
|
36
|
+
constructor(kind: 'rejected' | 'exists' | 'uncertain', message: string);
|
|
37
|
+
}
|
|
38
|
+
/** What a person does when an instance exists without a delivered bearer. Nothing is deleted automatically. */
|
|
39
|
+
export declare function instanceWithoutBearerMessage(hostname: string, detail: string): string;
|
|
40
|
+
export interface CreateInstanceRuntime {
|
|
41
|
+
fetch?: typeof fetch;
|
|
42
|
+
now: () => number;
|
|
43
|
+
/** Fetch timeout for the one create call (default 30s). */
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Creates the platform's ZITADEL instance on `customDomain` with one machine
|
|
48
|
+
* owner and returns its id and the owner's PAT. Sends exactly one request,
|
|
49
|
+
* never retries, and signs a fresh 60-second JWT for it. The PAT is returned
|
|
50
|
+
* to the caller only, never logged or put in an error.
|
|
51
|
+
*/
|
|
52
|
+
export declare function createZitadelInstance(input: {
|
|
53
|
+
customDomain: string;
|
|
54
|
+
privateKey: string;
|
|
55
|
+
instanceName: string;
|
|
56
|
+
firstOrgName: string;
|
|
57
|
+
owner: {
|
|
58
|
+
userName: string;
|
|
59
|
+
name: string;
|
|
60
|
+
};
|
|
61
|
+
}, runtime: CreateInstanceRuntime): Promise<{
|
|
62
|
+
instanceId: string;
|
|
63
|
+
pat: string;
|
|
64
|
+
}>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createSign, generateKeyPairSync } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* #283: foundry's way into a brand-new ZITADEL instance. ZITADEL's system API
|
|
4
|
+
* (`POST /system/v1/instances/_create`, permission `system.instance.write`,
|
|
5
|
+
* https://github.com/zitadel/zitadel/blob/main/proto/zitadel/system.proto)
|
|
6
|
+
* creates the instance with a machine owner (IAM_OWNER of that instance) and
|
|
7
|
+
* returns that owner's personal access token (`pat`) once, inline. foundry
|
|
8
|
+
* authenticates to the system API with a short-lived RS256 JWT signed by a
|
|
9
|
+
* keypair it generates: the public half goes into the container as a system
|
|
10
|
+
* API user, the private half stays in memory for one run. The calls follow
|
|
11
|
+
* ZITADEL's source and proto and have not been run against a live instance.
|
|
12
|
+
*/
|
|
13
|
+
/** The system API user's name; it is the JWT's `iss` and `sub`. */
|
|
14
|
+
export const SYSTEM_API_USER_NAME = 'foundry';
|
|
15
|
+
/** A fresh RSA keypair: the public key (PEM) for ZITADEL, the private key (PKCS#8 PEM) for signing. */
|
|
16
|
+
export function generateSystemApiKeypair() {
|
|
17
|
+
return generateKeyPairSync('rsa', {
|
|
18
|
+
modulusLength: 2048,
|
|
19
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
20
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The value of `ZITADEL_SYSTEMAPIUSERS`: JSON in ZITADEL's `SystemAPIUsers`
|
|
25
|
+
* shape (`cmd/defaults.yaml`), `KeyData` being the base64 of the public key
|
|
26
|
+
* PEM. With no `Memberships` ZITADEL grants exactly System/SYSTEM_OWNER.
|
|
27
|
+
*/
|
|
28
|
+
export function systemApiUsersEnv(publicKeyPem) {
|
|
29
|
+
return JSON.stringify({
|
|
30
|
+
[SYSTEM_API_USER_NAME]: { KeyData: Buffer.from(publicKeyPem).toString('base64') },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const b64url = (input) => Buffer.from(input).toString('base64url');
|
|
34
|
+
/** A 1-minute RS256 JWT for the system API: `iss` = `sub` = the user name, `aud` = the instance URL. */
|
|
35
|
+
export function signSystemApiJwt(privateKeyPem, audience, nowMs) {
|
|
36
|
+
const iat = Math.floor(nowMs / 1000);
|
|
37
|
+
const head = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
|
38
|
+
const body = b64url(JSON.stringify({
|
|
39
|
+
iss: SYSTEM_API_USER_NAME,
|
|
40
|
+
sub: SYSTEM_API_USER_NAME,
|
|
41
|
+
aud: audience,
|
|
42
|
+
iat,
|
|
43
|
+
exp: iat + 60,
|
|
44
|
+
}));
|
|
45
|
+
const signature = createSign('RSA-SHA256').update(`${head}.${body}`).sign(privateKeyPem);
|
|
46
|
+
return `${head}.${body}.${b64url(signature)}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* How a create failed. `rejected`: ZITADEL refused it for good before doing
|
|
50
|
+
* anything (bad key, bad request), so nothing exists. `exists`: an instance
|
|
51
|
+
* for the domain already exists. `uncertain`: the request may have reached
|
|
52
|
+
* ZITADEL (timeout, network error, 5xx, an unreadable answer), so an instance
|
|
53
|
+
* may exist. The caller records a marker for the last two.
|
|
54
|
+
*/
|
|
55
|
+
export class CreateInstanceError extends Error {
|
|
56
|
+
kind;
|
|
57
|
+
constructor(kind, message) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.kind = kind;
|
|
60
|
+
this.name = 'CreateInstanceError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** What a person does when an instance exists without a delivered bearer. Nothing is deleted automatically. */
|
|
64
|
+
export function instanceWithoutBearerMessage(hostname, detail) {
|
|
65
|
+
return `platform:auth: a ZITADEL instance may already exist for ${hostname} without a delivered bearer (${detail}). To recover, delete the instance with the system API (DELETE /system/v1/instances/{id}) or redeploy the ZITADEL database, remove the recorded key zitadel_platform_instance_undelivered, then apply again`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Creates the platform's ZITADEL instance on `customDomain` with one machine
|
|
69
|
+
* owner and returns its id and the owner's PAT. Sends exactly one request,
|
|
70
|
+
* never retries, and signs a fresh 60-second JWT for it. The PAT is returned
|
|
71
|
+
* to the caller only, never logged or put in an error.
|
|
72
|
+
*/
|
|
73
|
+
export async function createZitadelInstance(input, runtime) {
|
|
74
|
+
const fetchImpl = runtime.fetch ?? globalThis.fetch;
|
|
75
|
+
const url = `https://${input.customDomain}`;
|
|
76
|
+
let response;
|
|
77
|
+
try {
|
|
78
|
+
response = await fetchImpl(`${url}/system/v1/instances/_create`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
signal: AbortSignal.timeout(runtime.timeoutMs ?? 30_000),
|
|
81
|
+
headers: {
|
|
82
|
+
authorization: `Bearer ${signSystemApiJwt(input.privateKey, url, runtime.now())}`,
|
|
83
|
+
'content-type': 'application/json',
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
instanceName: input.instanceName,
|
|
87
|
+
firstOrgName: input.firstOrgName,
|
|
88
|
+
customDomain: input.customDomain,
|
|
89
|
+
machine: {
|
|
90
|
+
userName: input.owner.userName,
|
|
91
|
+
name: input.owner.name,
|
|
92
|
+
personalAccessToken: {
|
|
93
|
+
expirationDate: new Date(runtime.now() + 10 * 365 * 86_400_000).toISOString(),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new CreateInstanceError('uncertain', instanceWithoutBearerMessage(input.customDomain, 'the create request timed out or failed'));
|
|
101
|
+
}
|
|
102
|
+
if (response.status === 401) {
|
|
103
|
+
throw new CreateInstanceError('rejected', 'platform:auth: system API key rejected (HTTP 401); nothing was created');
|
|
104
|
+
}
|
|
105
|
+
if (response.status === 409) {
|
|
106
|
+
throw new CreateInstanceError('exists', instanceWithoutBearerMessage(input.customDomain, 'ZITADEL answered 409, already exists'));
|
|
107
|
+
}
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
// A 4xx is a refusal before anything was created; a 5xx may not be.
|
|
110
|
+
throw new CreateInstanceError(response.status < 500 ? 'rejected' : 'uncertain', response.status < 500
|
|
111
|
+
? `platform:auth: ZITADEL refused to create the instance (HTTP ${response.status}); nothing was created`
|
|
112
|
+
: instanceWithoutBearerMessage(input.customDomain, `ZITADEL answered HTTP ${response.status}`));
|
|
113
|
+
}
|
|
114
|
+
let body = {};
|
|
115
|
+
try {
|
|
116
|
+
body = (await response.json());
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Handled below.
|
|
120
|
+
}
|
|
121
|
+
if (!body.instanceId || !body.pat) {
|
|
122
|
+
throw new CreateInstanceError('uncertain', instanceWithoutBearerMessage(input.customDomain, 'the answer had no instance id or token'));
|
|
123
|
+
}
|
|
124
|
+
return { instanceId: body.instanceId, pat: body.pat };
|
|
125
|
+
}
|
package/dist/platform.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { OrgId } from './org.js';
|
|
|
4
4
|
* {@link DeclaredPlatform}. `enabled` turns the service's own provisioning
|
|
5
5
|
* on; `hostname` is where it is served (default `<service>.<apex>`,
|
|
6
6
|
* {@link defaultPlatformHostname}); `gitRef` is the release it runs, the
|
|
7
|
-
* same "declared, not discovered" shape `
|
|
7
|
+
* same "declared, not discovered" shape `HostingEntry.branch` uses for
|
|
8
8
|
* an org's own app (`org.ts`).
|
|
9
9
|
*/
|
|
10
10
|
export interface PlatformService {
|
package/dist/store/postgres.js
CHANGED
|
@@ -79,13 +79,9 @@ function rowToOrg(row) {
|
|
|
79
79
|
hosting: {
|
|
80
80
|
enabled: row.hostingEnabled,
|
|
81
81
|
usesDomain: row.hostingUsesDomain,
|
|
82
|
-
database: row.hostingDatabase,
|
|
83
82
|
tunnel: row.hostingTunnel,
|
|
84
|
-
gitBranch: row.hostingGitBranch,
|
|
85
|
-
buildPack: row.hostingBuildPack,
|
|
86
83
|
issuerApps: row.hostingIssuerApps,
|
|
87
|
-
|
|
88
|
-
appEnv: row.hostingAppEnv,
|
|
84
|
+
entries: row.hostingEntries,
|
|
89
85
|
},
|
|
90
86
|
sso: { enabled: row.ssoEnabled },
|
|
91
87
|
guests: { enabled: row.guestsEnabled },
|
|
@@ -122,13 +118,9 @@ function orgToRow(org) {
|
|
|
122
118
|
siteUsesDomain: org.site.usesDomain,
|
|
123
119
|
hostingEnabled: org.hosting.enabled,
|
|
124
120
|
hostingUsesDomain: org.hosting.usesDomain,
|
|
125
|
-
hostingDatabase: org.hosting.database,
|
|
126
121
|
hostingTunnel: org.hosting.tunnel,
|
|
127
|
-
hostingGitBranch: org.hosting.gitBranch,
|
|
128
|
-
hostingBuildPack: org.hosting.buildPack,
|
|
129
122
|
hostingIssuerApps: org.hosting.issuerApps,
|
|
130
|
-
|
|
131
|
-
hostingAppEnv: org.hosting.appEnv ?? {},
|
|
123
|
+
hostingEntries: org.hosting.entries,
|
|
132
124
|
ssoEnabled: org.sso.enabled,
|
|
133
125
|
guestsEnabled: org.guests.enabled,
|
|
134
126
|
soloEnabled: org.solo.enabled,
|