@wtfalch/foundry 0.15.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/declare.d.ts +2 -2
- package/dist/bootstrap/declare.js +6 -6
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/migrations/0034_owners.sql +42 -0
- package/dist/migrations/0035_chat_on_by_default.sql +10 -0
- package/dist/org.d.ts +80 -17
- package/dist/org.js +221 -18
- package/dist/plan/agora.d.ts +19 -1
- package/dist/plan/agora.js +37 -2
- package/dist/plan/chat-matrix.d.ts +99 -0
- package/dist/plan/chat-matrix.js +194 -0
- package/dist/plan/diff.d.ts +5 -3
- package/dist/plan/diff.js +143 -83
- package/dist/plan/entry-ai-key.d.ts +58 -0
- package/dist/plan/entry-ai-key.js +209 -0
- package/dist/plan/entry-files-key.d.ts +82 -0
- package/dist/plan/entry-files-key.js +327 -0
- package/dist/plan/executor.d.ts +244 -16
- package/dist/plan/executor.js +866 -84
- package/dist/plan/hostnames.d.ts +14 -1
- package/dist/plan/hostnames.js +26 -1
- package/dist/plan/index.d.ts +1 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/plan-apply.d.ts +2 -0
- package/dist/plan/plan-apply.js +42 -2
- package/dist/plan/platform-org.d.ts +17 -5
- package/dist/plan/platform-org.js +15 -7
- package/dist/plan/platform-providers.js +4 -3
- package/dist/plan/proxy-labels.d.ts +42 -0
- package/dist/plan/proxy-labels.js +73 -0
- package/dist/plan/zitadel-machine-user.d.ts +5 -1
- package/dist/plan/zitadel-machine-user.js +5 -1
- package/dist/plan/zitadel-project-roles.d.ts +25 -0
- package/dist/plan/zitadel-project-roles.js +104 -0
- package/dist/store/postgres.js +4 -17
- package/dist/store/schema.d.ts +13 -45
- package/dist/store/schema.js +5 -8
- package/package.json +1 -1
package/dist/plan/diff.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
|
-
import { APP_ENTRY_KEY } from '../org.js';
|
|
3
|
-
import { CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, } from './agora.js';
|
|
2
|
+
import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
|
|
3
|
+
import { CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
4
4
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
5
|
+
import { planEntryAiKeys } from './entry-ai-key.js';
|
|
6
|
+
import { planFilesKeys } from './entry-files-key.js';
|
|
5
7
|
import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
|
|
6
8
|
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
|
|
7
9
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
|
|
8
10
|
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, versionNotAppliedLine, } from './platform-diff.js';
|
|
9
11
|
import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
|
|
12
|
+
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec } from './proxy-labels.js';
|
|
10
13
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
11
14
|
export function toPlan(detail) {
|
|
12
15
|
return {
|
|
@@ -40,7 +43,7 @@ function resourceKey(system, kind, key) {
|
|
|
40
43
|
* `planAiOrganisation`, each planned before its own system's limit step
|
|
41
44
|
* ({@link planStorage}, {@link planAiBudget}) so an org has somewhere for a
|
|
42
45
|
* limit to land, and AI use is switched on, before either limit is ever set.
|
|
43
|
-
* `
|
|
46
|
+
* `planOwners` below adds one more singleton, in a different shape from the
|
|
44
47
|
* rest: the org's owner, invited by ZITADEL email, idempotent on the
|
|
45
48
|
* *recorded* resource id (`recordedIds`, sourced from
|
|
46
49
|
* `AppliedState.serviceIds`) rather than a live resource, since a human user
|
|
@@ -57,9 +60,9 @@ function resourceKey(system, kind, key) {
|
|
|
57
60
|
* Agora). #173 adds one more, gated on `org.hosting.enabled` like
|
|
58
61
|
* {@link planApp} itself: {@link planJobsRegistration}, registering the org's
|
|
59
62
|
* app with the platform's jobs scheduler, idempotent on the *recorded*
|
|
60
|
-
* resource id like {@link
|
|
63
|
+
* resource id like {@link planOwners} rather than a live resource, since
|
|
61
64
|
* nothing in `live` can report a scheduler registration (no reader exists for
|
|
62
|
-
* it). #267 adds one more, right after {@link
|
|
65
|
+
* it). #267 adds one more, right after {@link planOwners} and before every
|
|
63
66
|
* app-creating step below: {@link planLoginHost}, the org's own company
|
|
64
67
|
* login host -- also idempotent on the *recorded* resource id, since
|
|
65
68
|
* `live` has no notion of a ZITADEL trusted domain or a login binding
|
|
@@ -78,9 +81,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
78
81
|
const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady);
|
|
79
82
|
if (issuer.step)
|
|
80
83
|
plannedSteps.push(issuer.step);
|
|
81
|
-
|
|
82
|
-
if (owner)
|
|
83
|
-
plannedSteps.push(owner);
|
|
84
|
+
plannedSteps.push(...planOwners(org, recordedIds, platformReadiness.authReady));
|
|
84
85
|
// #267: after the ZITADEL org step, before any app's own issuer
|
|
85
86
|
// application. Apps created later in this run are not in the binding yet;
|
|
86
87
|
// the next run replans this step and repoints them.
|
|
@@ -107,16 +108,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
107
108
|
const mailCopy = planMailCopy(org, byKey, recordedIds);
|
|
108
109
|
if (mailCopy)
|
|
109
110
|
plannedSteps.push(mailCopy);
|
|
110
|
-
const chat = planChat(org, byKey, claimed);
|
|
111
|
-
if (chat)
|
|
112
|
-
plannedSteps.push(chat);
|
|
111
|
+
const chat = planChat(org, byKey, claimed, recordedIds, agoraHost, issuer.step !== null);
|
|
112
|
+
if (chat.step)
|
|
113
|
+
plannedSteps.push(chat.step);
|
|
113
114
|
const calls = planCalls(org, byKey, claimed, platformReadiness.callsReady);
|
|
114
115
|
if (calls.step)
|
|
115
116
|
plannedSteps.push(calls.step);
|
|
116
117
|
// Right after the Matrix stack (#145): its own executor
|
|
117
118
|
// (`executor.ts`'s `coolifyCallsService`) reads the Synapse/MAS addresses
|
|
118
119
|
// and MAS client credentials `coolifyCalls` just recorded, the same
|
|
119
|
-
// ordering `
|
|
120
|
+
// ordering `planOwners` relies on for `zitadel:issuer`.
|
|
120
121
|
const callsService = planCallsService(org, byKey, claimed, platformReadiness.callsReady, recordedIds, calls.blocked !== null, 'error' in agoraHost ? null : callsTokenHostFrom(agoraHost));
|
|
121
122
|
if (callsService.step)
|
|
122
123
|
plannedSteps.push(callsService.step);
|
|
@@ -191,6 +192,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
191
192
|
// After the apps: the broker entry's key is minted into each app's env.
|
|
192
193
|
const brokerEntries = planBrokerEntries(org, recordedIds, plannedSteps, options.broker ?? false);
|
|
193
194
|
plannedSteps.push(...brokerEntries.steps);
|
|
195
|
+
// F6a: after the apps and the org's own ai key, which it mints below.
|
|
196
|
+
const entryAiKeys = planEntryAiKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
|
|
197
|
+
plannedSteps.push(...entryAiKeys.steps);
|
|
198
|
+
// F6b: after the owner, the apps and the files organisation it needs.
|
|
199
|
+
const filesKeys = planFilesKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
|
|
200
|
+
plannedSteps.push(...filesKeys.steps);
|
|
194
201
|
const tunnel = planTunnel(org, byKey, claimed, recordedIds);
|
|
195
202
|
// #238: a tunnel that is being provisioned waits for an active zone; parking
|
|
196
203
|
// one never does.
|
|
@@ -250,6 +257,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
250
257
|
// orphan a second live key.
|
|
251
258
|
if (aiKey.blocked)
|
|
252
259
|
unmanaged.push({ description: aiKey.blocked, key: 'ai:key' });
|
|
260
|
+
unmanaged.push(...filesKeys.blocked);
|
|
261
|
+
unmanaged.push(...entryAiKeys.blocked);
|
|
253
262
|
// Same for a jobs registration that cannot run (no scheduler wired) or must
|
|
254
263
|
// not run again (minted, never delivered).
|
|
255
264
|
if (jobsRegistration.blocked) {
|
|
@@ -275,6 +284,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
275
284
|
}
|
|
276
285
|
if (calls.blocked)
|
|
277
286
|
unmanaged.push({ description: calls.blocked, key: 'coolify:calls' });
|
|
287
|
+
if (chat.blocked)
|
|
288
|
+
unmanaged.push({ description: chat.blocked, key: 'coolify:chat' });
|
|
278
289
|
if (callsService.blocked) {
|
|
279
290
|
unmanaged.push({ description: callsService.blocked, key: 'coolify:calls-service' });
|
|
280
291
|
}
|
|
@@ -377,11 +388,11 @@ function planIssuer(org, byKey, claimed, authReady) {
|
|
|
377
388
|
};
|
|
378
389
|
}
|
|
379
390
|
/**
|
|
380
|
-
* The org's
|
|
391
|
+
* The org's owners, one step each, each invited by ZITADEL email once the org's ZITADEL org
|
|
381
392
|
* exists (goal line: "the org's owner is invited by ZITADEL email and sets a
|
|
382
393
|
* passkey — no password passes through foundry"). A per-org singleton like
|
|
383
394
|
* every other planner here, but idempotent on a different signal: the
|
|
384
|
-
* *recorded* resource id (`recordedIds['zitadel:owner:<orgId>']`, written by
|
|
395
|
+
* *recorded* resource id (`recordedIds['zitadel:owner:<orgId>:<email>']`, email lowercased, written by
|
|
385
396
|
* `plan-apply.ts`'s `runSteps` from this step's own `resourceId` — the
|
|
386
397
|
* invited user id) rather than a live resource. `ZitadelReader.listResources`
|
|
387
398
|
* (`@wtfalch/providers`' `ZitadelClient.listResources`) reads projects and
|
|
@@ -404,45 +415,51 @@ function planIssuer(org, byKey, claimed, authReady) {
|
|
|
404
415
|
* nothing here puts it anywhere beyond what audit already records for the
|
|
405
416
|
* org.
|
|
406
417
|
*/
|
|
407
|
-
function
|
|
408
|
-
if (!org.owner)
|
|
409
|
-
return null;
|
|
410
|
-
const key = resourceKey('zitadel', 'owner', org.id);
|
|
411
|
-
if (recordedIds[key])
|
|
412
|
-
return null; // already invited -- a re-run plans nothing.
|
|
418
|
+
function planOwners(org, recordedIds, authReady) {
|
|
413
419
|
// #177: nothing to invite into yet -- `planIssuer` already reports the
|
|
414
|
-
// platform's sign-in as blocked, so
|
|
420
|
+
// platform's sign-in as blocked, so these steps stay silent rather than
|
|
415
421
|
// repeating it.
|
|
416
422
|
if (!authReady)
|
|
417
|
-
return
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
423
|
+
return [];
|
|
424
|
+
const steps = [];
|
|
425
|
+
org.owners.forEach((owner, index) => {
|
|
426
|
+
// Every owner is keyed by its lowercased email (as `validateOwners` compares
|
|
427
|
+
// it), so reordering the list never moves a key onto another person.
|
|
428
|
+
const key = `${org.id}:${owner.email.toLowerCase()}`;
|
|
429
|
+
if (recordedIds[resourceKey('zitadel', 'owner', key)])
|
|
430
|
+
return; // already invited.
|
|
431
|
+
steps.push({
|
|
432
|
+
action: {
|
|
433
|
+
system: 'zitadel',
|
|
434
|
+
kind: 'owner',
|
|
435
|
+
key,
|
|
436
|
+
desired: {
|
|
437
|
+
email: owner.email,
|
|
438
|
+
givenName: owner.givenName,
|
|
439
|
+
familyName: owner.familyName,
|
|
440
|
+
},
|
|
427
441
|
},
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
442
|
+
step: {
|
|
443
|
+
description: index === 0
|
|
444
|
+
? `invite ${org.slug}'s owner by email`
|
|
445
|
+
: `invite ${org.slug}'s owner ${index + 1} by email`,
|
|
446
|
+
destroys: false,
|
|
447
|
+
migrates: false,
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
return steps;
|
|
435
452
|
}
|
|
436
453
|
/**
|
|
437
454
|
* #267: the org's own company login host (`login-host.ts`'s
|
|
438
455
|
* `loginHostnameFor`) -- `auth.<domain>` once the org has one,
|
|
439
456
|
* `<slug>-auth.oikos.host` for tier 1. Idempotent on the *recorded*
|
|
440
|
-
* resource id, the same shape `
|
|
457
|
+
* resource id, the same shape `planOwners` above uses and for the same
|
|
441
458
|
* reason: `ZitadelReader.listResources` has no notion of a trusted domain
|
|
442
459
|
* or a login binding, so there is nothing in `live` this step could ever
|
|
443
460
|
* claim or compare against.
|
|
444
461
|
*
|
|
445
|
-
* Unlike `
|
|
462
|
+
* Unlike `planOwners`, a re-run is not simply "already done or not": the
|
|
446
463
|
* recorded value is compared against {@link loginHostnameFor}'s own value
|
|
447
464
|
* for *this* apply, so an org moving from tier 1 onto its own domain (or
|
|
448
465
|
* losing one) replans this step rather than staying silent forever once
|
|
@@ -455,8 +472,8 @@ function planOwner(org, recordedIds, authReady) {
|
|
|
455
472
|
* there is no "undelivered"
|
|
456
473
|
* guard to check the way `planJobsRegistration`'s own does.
|
|
457
474
|
*
|
|
458
|
-
* Ordered right after `
|
|
459
|
-
* step), for the same reason `
|
|
475
|
+
* Ordered right after `planOwners` (not after mail, chat, or any hosting
|
|
476
|
+
* step), for the same reason `planOwners`'s own doc comment gives: on a
|
|
460
477
|
* first-ever apply the ZITADEL org this step's executor needs
|
|
461
478
|
* (`context.recordedIds().zitadel_org`) has already been recorded by the
|
|
462
479
|
* issuer step two steps before it in the same run, and this step itself
|
|
@@ -482,7 +499,7 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
|
|
|
482
499
|
}
|
|
483
500
|
// #177: nothing to add a trusted domain to yet -- `planIssuer` already
|
|
484
501
|
// reports the platform's sign-in as blocked, so this step stays silent
|
|
485
|
-
// rather than repeating it, the same rule `
|
|
502
|
+
// rather than repeating it, the same rule `planOwners` follows.
|
|
486
503
|
if (!authReady)
|
|
487
504
|
return none;
|
|
488
505
|
// Isolation between orgs rests on zitadel-login's binding, and repointing
|
|
@@ -519,13 +536,6 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
|
|
|
519
536
|
blocked: null,
|
|
520
537
|
};
|
|
521
538
|
}
|
|
522
|
-
/**
|
|
523
|
-
* #162: the flat scope id `executor.ts`'s `ensureOwnerMailbox` records once
|
|
524
|
-
* it has given the org's owner a mailbox (`MAIL_OWNER_MAILBOX_SCOPE_KEY`
|
|
525
|
-
* there) — restated here rather than imported, the same rule this file's own
|
|
526
|
-
* `LOG_DIR` precedent follows for a value shared with `executor.ts`.
|
|
527
|
-
*/
|
|
528
|
-
const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
|
|
529
539
|
/** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
|
|
530
540
|
const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
|
|
531
541
|
/** Restated from `executor.ts`, the same rule: the app id of the org tunnel's cloudflared connector. */
|
|
@@ -583,13 +593,17 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
|
|
|
583
593
|
// (`mailboxBlocked`), the same "blocked, not planned" pattern a blocked
|
|
584
594
|
// Agora already follows (PR 180) -- the rest of this step, and every
|
|
585
595
|
// other step in the org's plan, is unaffected.
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
596
|
+
// One mailbox per owner (`ownerMailboxes`): the first owner's address falls
|
|
597
|
+
// back to `mail.ownerMailboxAddress`; another owner with no `mailboxAddress`
|
|
598
|
+
// simply gets none. Only the first owner's missing address is reported
|
|
599
|
+
// blocked, as before.
|
|
600
|
+
const ownerBoxes = ownerMailboxes(org);
|
|
601
|
+
const mailboxesOwed = ownerBoxes.filter((b) => b.address !== null && !ownerMailboxRecorded(b, recordedIds)).length;
|
|
602
|
+
const firstBox = ownerBoxes[0];
|
|
603
|
+
const mailboxBlocked = firstBox && !ownerMailboxRecorded(firstBox, recordedIds) && firstBox.address === null
|
|
590
604
|
? `${org.slug}'s owner mailbox is blocked, not planned: mail.ownerMailboxAddress is not declared`
|
|
591
605
|
: null;
|
|
592
|
-
const mailboxOwed =
|
|
606
|
+
const mailboxOwed = mailboxesOwed > 0;
|
|
593
607
|
// #230: declared role mailboxes not yet recorded as created. Names only
|
|
594
608
|
// ever count here, never appear in a description, same as the owner's address.
|
|
595
609
|
// Recorded per address, so a changed mail domain owes its own set. A hosted
|
|
@@ -597,7 +611,9 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
|
|
|
597
611
|
const createdRoles = new Set((recordedIds[MAIL_ROLE_MAILBOXES_SCOPE_KEY] ?? '').split(','));
|
|
598
612
|
const roleDomain = (org.mail.usesDomain && org.domain) || recordedIds[MAIL_DOMAIN_SCOPE_KEY];
|
|
599
613
|
const rolesOwed = (org.mail.roleMailboxes ?? []).filter((r) => !roleDomain || !createdRoles.has(`${r}@${roleDomain}`.toLowerCase())).length;
|
|
600
|
-
const ownerNote = mailboxOwed
|
|
614
|
+
const ownerNote = mailboxOwed
|
|
615
|
+
? `give ${org.slug}'s ${mailboxesOwed > 1 ? `${mailboxesOwed} owners a mailbox each` : 'owner a mailbox'}`
|
|
616
|
+
: '';
|
|
601
617
|
const roleNote = rolesOwed > 0 ? `create ${rolesOwed} role mailbox(es) for ${org.slug}` : '';
|
|
602
618
|
const mailboxWork = [ownerNote, roleNote].filter(Boolean).join(' and ');
|
|
603
619
|
const mailboxNote = mailboxWork ? ` and ${mailboxWork}` : '';
|
|
@@ -714,45 +730,68 @@ function planMailCopy(org, byKey, recordedIds) {
|
|
|
714
730
|
},
|
|
715
731
|
};
|
|
716
732
|
}
|
|
717
|
-
function planChat(org, byKey, claimed) {
|
|
718
|
-
|
|
719
|
-
//
|
|
733
|
+
function planChat(org, byKey, claimed, recordedIds, reading, issuerPlanned) {
|
|
734
|
+
const none = { step: null, blocked: null };
|
|
735
|
+
// Chat off: plan nothing, and leave any live chat unclaimed so it is
|
|
736
|
+
// reported in `unmanaged` and never adopted or destroyed.
|
|
720
737
|
if (!org.chat.enabled)
|
|
721
|
-
return
|
|
738
|
+
return none;
|
|
722
739
|
const key = resourceKey('coolify', 'chat', org.id);
|
|
723
740
|
const live = byKey.get(key);
|
|
724
741
|
if (live)
|
|
725
742
|
claimed.add(key);
|
|
726
743
|
const desiredUsesDomain = org.chat.usesDomain;
|
|
727
744
|
if (!live) {
|
|
745
|
+
// Chat verifies ZITADEL tokens, so it needs the issuer URL and the org's
|
|
746
|
+
// ZITADEL org (rule 2: blocked, not a step that throws). The ZITADEL org
|
|
747
|
+
// may be created by the issuer step earlier in this very plan.
|
|
748
|
+
const issuer = 'error' in reading ? null : reading.issuer;
|
|
749
|
+
if (!issuer) {
|
|
750
|
+
return {
|
|
751
|
+
step: null,
|
|
752
|
+
blocked: `${org.slug}'s chat is blocked, not planned: the host supplies no issuer`,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
if (!recordedIds[ZITADEL_ORG_SCOPE_KEY] && !issuerPlanned) {
|
|
756
|
+
return {
|
|
757
|
+
step: null,
|
|
758
|
+
blocked: `${org.slug}'s chat is blocked, not planned: the org has no ZITADEL org yet`,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
728
761
|
// Chat is provisioned at level 1 on a wtfalch `server_name` regardless
|
|
729
762
|
// of `usesDomain` (#7 "Chat").
|
|
730
763
|
return {
|
|
731
|
-
action: {
|
|
732
|
-
system: 'coolify',
|
|
733
|
-
kind: 'chat',
|
|
734
|
-
key: org.id,
|
|
735
|
-
desired: { usesDomain: desiredUsesDomain },
|
|
736
|
-
},
|
|
737
764
|
step: {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
765
|
+
action: {
|
|
766
|
+
system: 'coolify',
|
|
767
|
+
kind: 'chat',
|
|
768
|
+
key: org.id,
|
|
769
|
+
desired: { usesDomain: desiredUsesDomain, issuer },
|
|
770
|
+
},
|
|
771
|
+
step: {
|
|
772
|
+
description: `provision chat for ${org.slug} on a wtfalch server_name`,
|
|
773
|
+
destroys: false,
|
|
774
|
+
migrates: false,
|
|
775
|
+
},
|
|
741
776
|
},
|
|
777
|
+
blocked: null,
|
|
742
778
|
};
|
|
743
779
|
}
|
|
744
780
|
const liveUsesDomain = Boolean(live.config.usesDomain);
|
|
745
781
|
if (liveUsesDomain === desiredUsesDomain)
|
|
746
|
-
return
|
|
782
|
+
return none;
|
|
747
783
|
const domain = org.domain ?? 'its domain';
|
|
748
784
|
if (!liveUsesDomain && desiredUsesDomain) {
|
|
749
785
|
return {
|
|
750
|
-
action: { system: 'coolify', kind: 'chat', key: org.id, desired: { usesDomain: true } },
|
|
751
786
|
step: {
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
787
|
+
action: { system: 'coolify', kind: 'chat', key: org.id, desired: { usesDomain: true } },
|
|
788
|
+
step: {
|
|
789
|
+
description: `re-key ${org.slug}'s chat onto ${domain}, copying rooms and messages so history reads continuous`,
|
|
790
|
+
destroys: true,
|
|
791
|
+
migrates: true,
|
|
792
|
+
},
|
|
755
793
|
},
|
|
794
|
+
blocked: null,
|
|
756
795
|
};
|
|
757
796
|
}
|
|
758
797
|
// The reverse (usesDomain true -> false): legal in the schema, but the
|
|
@@ -760,12 +799,15 @@ function planChat(org, byKey, claimed) {
|
|
|
760
799
|
// visibility; whoever wires a real StepExecutor (#20) must fail loudly on
|
|
761
800
|
// it rather than run it, exactly as #7 amendment 3 requires.
|
|
762
801
|
return {
|
|
763
|
-
action: { system: 'coolify', kind: 'chat', key: org.id, desired: { usesDomain: false } },
|
|
764
802
|
step: {
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
803
|
+
action: { system: 'coolify', kind: 'chat', key: org.id, desired: { usesDomain: false } },
|
|
804
|
+
step: {
|
|
805
|
+
description: `reverse ${org.slug}'s chat re-key back onto its original server_name — not yet built (#7 amendment 3)`,
|
|
806
|
+
destroys: true,
|
|
807
|
+
migrates: true,
|
|
808
|
+
},
|
|
768
809
|
},
|
|
810
|
+
blocked: null,
|
|
769
811
|
};
|
|
770
812
|
}
|
|
771
813
|
/**
|
|
@@ -1114,7 +1156,7 @@ function planAiBudget(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader =
|
|
|
1114
1156
|
* #80 step 3: the org's own ai key, minted once its organisation is
|
|
1115
1157
|
* provisioned and offered ({@link planAiOrganisation}). Idempotent on the
|
|
1116
1158
|
* *recorded* resource id (`recordedIds['ai:key:<orgId>']`), the same shape
|
|
1117
|
-
* {@link
|
|
1159
|
+
* {@link planOwners} uses and for the same reason: ai's `issueOrganisationKey`
|
|
1118
1160
|
* (`wtfalch/ai#108`/`#113`) shows a key's secret exactly once and offers no
|
|
1119
1161
|
* way to list or look up an already-minted key by name, so there is nothing
|
|
1120
1162
|
* in `live` an existing key could ever appear as. `permissions` mirrors
|
|
@@ -1336,7 +1378,7 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
|
|
|
1336
1378
|
if (live)
|
|
1337
1379
|
claimed.add(key);
|
|
1338
1380
|
const error = 'error' in reading ? reading.error : null;
|
|
1339
|
-
const host = 'error' in reading ? NO_AGORA_HOST_VALUES : reading;
|
|
1381
|
+
const host = withRecordedMessagingProject('error' in reading ? NO_AGORA_HOST_VALUES : reading, recordedIds);
|
|
1340
1382
|
const missing = agoraHostMissing(host);
|
|
1341
1383
|
if (missing.length > 0) {
|
|
1342
1384
|
// A fixed text, never the port's own message: this line is part of the
|
|
@@ -1593,6 +1635,24 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
1593
1635
|
blocked,
|
|
1594
1636
|
};
|
|
1595
1637
|
}
|
|
1638
|
+
// A `*` entry's proxy labels reach an app that is already live: the label
|
|
1639
|
+
// set is patched and the app redeployed once its hash differs from the one
|
|
1640
|
+
// `coolifyApp` recorded.
|
|
1641
|
+
const appliedLabels = recordedIds[appLabelsAppliedScopeKey(entryKey)];
|
|
1642
|
+
const labelSpec = entryLabelSpec(org, entry, appliedLabels !== undefined);
|
|
1643
|
+
if (labelSpec && entryLabelHash(labelSpec) !== appliedLabels) {
|
|
1644
|
+
return {
|
|
1645
|
+
step: {
|
|
1646
|
+
action,
|
|
1647
|
+
step: {
|
|
1648
|
+
description: `apply ${org.slug}'s ${label} proxy labels and redeploy the app`,
|
|
1649
|
+
destroys: false,
|
|
1650
|
+
migrates: false,
|
|
1651
|
+
},
|
|
1652
|
+
},
|
|
1653
|
+
blocked,
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1596
1656
|
// A changed `folder`, `port` or `autoDeploy` reaches an app that is already live.
|
|
1597
1657
|
const want = appSettingsOf(entry);
|
|
1598
1658
|
const have = appliedAppSettings(recordedIds, entryKey);
|
|
@@ -1663,7 +1723,7 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer) {
|
|
|
1663
1723
|
* #173: register the org's own app with the platform's jobs scheduler
|
|
1664
1724
|
* (https://github.com/wtfalch/jobs's admin API, jobs#62), so the scheduler
|
|
1665
1725
|
* knows where to deliver that org's schedules. Idempotent on the *recorded*
|
|
1666
|
-
* resource id, the same shape {@link
|
|
1726
|
+
* resource id, the same shape {@link planOwners} uses for inviting a human
|
|
1667
1727
|
* owner by email: nothing in `live` can report "is this org registered" (no
|
|
1668
1728
|
* reader exists for the scheduler's own admin API), and the admin API's own
|
|
1669
1729
|
* `POST /admin/projects` is itself idempotent by `(name, baseUrl)` -- a
|
|
@@ -2038,7 +2098,7 @@ function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired) {
|
|
|
2038
2098
|
* Plan gap A9: an org's customers sign in through a second ZITADEL org with
|
|
2039
2099
|
* self-registration on, granted the org's app project. Planned once an entry
|
|
2040
2100
|
* signs in its customers (`uses.auth.users: 'customers'`).
|
|
2041
|
-
* Idempotent on the recorded customers org id, like `
|
|
2101
|
+
* Idempotent on the recorded customers org id, like `planOwners`. Once
|
|
2042
2102
|
* recorded, the live project grant to that org is claimed so it is not
|
|
2043
2103
|
* listed as unmanaged.
|
|
2044
2104
|
*/
|
|
@@ -2068,7 +2128,7 @@ function planCustomers(org, live, claimed, recordedIds) {
|
|
|
2068
2128
|
/**
|
|
2069
2129
|
* #84: a Cloudflare Access gate on the org's app hostname, letting only
|
|
2070
2130
|
* each entry's `accessEmails` through. Idempotent on the recorded Access app id,
|
|
2071
|
-
* like `
|
|
2131
|
+
* like `planOwners`: Cloudflare's live listing has no Access apps. A later
|
|
2072
2132
|
* change to the email list is not reconciled yet.
|
|
2073
2133
|
*/
|
|
2074
2134
|
function planAccess(org, recordedIds) {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type DeclaredOrg, type HostingEntry } from '../org.js';
|
|
2
|
+
import type { PlannedStep } from './diff.js';
|
|
3
|
+
import type { OrgPlatformReadiness } from './platform-org.js';
|
|
4
|
+
/**
|
|
5
|
+
* F6a: one ai key per entry with `uses.ai`, minted below the org's own ai key
|
|
6
|
+
* (`ai:key:<orgId>`), delivered into that entry's env and nowhere else.
|
|
7
|
+
*/
|
|
8
|
+
/** How long an entry's key lives. ai refuses more than 365 and anything past its minter's own expiry. */
|
|
9
|
+
export declare const AI_ENTRY_KEY_DAYS = 365;
|
|
10
|
+
/** A key with fewer days left than this is renewed. */
|
|
11
|
+
export declare const AI_ENTRY_RENEW_WINDOW_DAYS = 30;
|
|
12
|
+
/** Flat scope ids (no `:`) per entry. Entry keys hold neither `:` nor `.`. */
|
|
13
|
+
export declare const entryAiUndeliveredScopeKey: (entryKey: string) => string;
|
|
14
|
+
/** The ISO time the entry's live key expires. */
|
|
15
|
+
export declare const entryAiExpiresScopeKey: (entryKey: string) => string;
|
|
16
|
+
/** What the last apply wrote: permissions, offerings, webhook address, ai address. Never a secret. */
|
|
17
|
+
export declare const entryAiAppliedScopeKey: (entryKey: string) => string;
|
|
18
|
+
/** Set before the webhook is registered and cleared once its secret is in the env. */
|
|
19
|
+
export declare const entryAiWebhookPendingScopeKey: (entryKey: string) => string;
|
|
20
|
+
/** The id of the key whose env write finished, before the app restart that completes the step. */
|
|
21
|
+
export declare const entryAiWrittenScopeKey: (entryKey: string) => string;
|
|
22
|
+
/** The expiry ai returned for the key above. */
|
|
23
|
+
export declare const entryAiPendingExpiresScopeKey: (entryKey: string) => string;
|
|
24
|
+
/** `{ expiresAt, minter }` of a key ai clamped under the renewal window; renewing it again under the same minter only repeats. */
|
|
25
|
+
export declare const entryAiClampedScopeKey: (entryKey: string) => string;
|
|
26
|
+
/** The id of a key that was live when its entry stopped declaring `uses.ai`, once handled. */
|
|
27
|
+
export declare const entryAiRetiredScopeKey: (entryKey: string) => string;
|
|
28
|
+
/**
|
|
29
|
+
* REVOKE SEAM (ai#157, A1b, not built): the ids of keys left live because
|
|
30
|
+
* `AiOrganisationPort.revokeKey` is absent or failed, as a JSON array. They
|
|
31
|
+
* expire on their own; the plan lists each.
|
|
32
|
+
*/
|
|
33
|
+
export declare const entryAiUnrevokedScopeKey: (entryKey: string) => string;
|
|
34
|
+
export declare const ENTRY_AI_UNREVOKED_PREFIX = "ai_key_unrevoked_";
|
|
35
|
+
export declare function parseIdList(raw: string | undefined): string[];
|
|
36
|
+
export declare const entryAiKeyName: (org: DeclaredOrg, entry: HostingEntry) => string;
|
|
37
|
+
export interface EntryAiWant {
|
|
38
|
+
permissions: string[];
|
|
39
|
+
offerings: string[];
|
|
40
|
+
/** `https://<first hostname><webhookPath>`, or null without `webhookPath`. */
|
|
41
|
+
webhook: string | null;
|
|
42
|
+
aiUrl: string;
|
|
43
|
+
}
|
|
44
|
+
export declare function wantedEntryAi(org: DeclaredOrg, entry: HostingEntry, aiUrl: string): EntryAiWant;
|
|
45
|
+
export type EntryAiMode = 'mint' | 'renew' | 'settings' | 'finish' | 'retire';
|
|
46
|
+
/**
|
|
47
|
+
* One step per entry whose key is missing, near expiry or whose declared
|
|
48
|
+
* settings changed. Blocked, not planned (rule 2), while the org's key, the
|
|
49
|
+
* entry's app or the ai address is not recorded, or after a mint whose
|
|
50
|
+
* delivery failed: a second mint would leave two live keys.
|
|
51
|
+
*/
|
|
52
|
+
export declare function planEntryAiKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly PlannedStep[], readiness: OrgPlatformReadiness, now: number): {
|
|
53
|
+
steps: PlannedStep[];
|
|
54
|
+
blocked: Array<{
|
|
55
|
+
description: string;
|
|
56
|
+
key: string;
|
|
57
|
+
}>;
|
|
58
|
+
};
|