@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.
Files changed (39) hide show
  1. package/dist/bootstrap/declare.d.ts +2 -2
  2. package/dist/bootstrap/declare.js +6 -6
  3. package/dist/index.d.ts +3 -3
  4. package/dist/index.js +2 -2
  5. package/dist/migrations/0034_owners.sql +42 -0
  6. package/dist/migrations/0035_chat_on_by_default.sql +10 -0
  7. package/dist/org.d.ts +80 -17
  8. package/dist/org.js +221 -18
  9. package/dist/plan/agora.d.ts +19 -1
  10. package/dist/plan/agora.js +37 -2
  11. package/dist/plan/chat-matrix.d.ts +99 -0
  12. package/dist/plan/chat-matrix.js +194 -0
  13. package/dist/plan/diff.d.ts +5 -3
  14. package/dist/plan/diff.js +143 -83
  15. package/dist/plan/entry-ai-key.d.ts +58 -0
  16. package/dist/plan/entry-ai-key.js +209 -0
  17. package/dist/plan/entry-files-key.d.ts +82 -0
  18. package/dist/plan/entry-files-key.js +327 -0
  19. package/dist/plan/executor.d.ts +244 -16
  20. package/dist/plan/executor.js +866 -84
  21. package/dist/plan/hostnames.d.ts +14 -1
  22. package/dist/plan/hostnames.js +26 -1
  23. package/dist/plan/index.d.ts +1 -0
  24. package/dist/plan/index.js +1 -0
  25. package/dist/plan/plan-apply.d.ts +2 -0
  26. package/dist/plan/plan-apply.js +42 -2
  27. package/dist/plan/platform-org.d.ts +17 -5
  28. package/dist/plan/platform-org.js +15 -7
  29. package/dist/plan/platform-providers.js +4 -3
  30. package/dist/plan/proxy-labels.d.ts +42 -0
  31. package/dist/plan/proxy-labels.js +73 -0
  32. package/dist/plan/zitadel-machine-user.d.ts +5 -1
  33. package/dist/plan/zitadel-machine-user.js +5 -1
  34. package/dist/plan/zitadel-project-roles.d.ts +25 -0
  35. package/dist/plan/zitadel-project-roles.js +104 -0
  36. package/dist/store/postgres.js +4 -17
  37. package/dist/store/schema.d.ts +13 -45
  38. package/dist/store/schema.js +5 -8
  39. 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
- * `planOwner` below adds one more singleton, in a different shape from the
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 planOwner} rather than a live resource, since
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 planOwner} and before every
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
- const owner = planOwner(org, recordedIds, platformReadiness.authReady);
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 `planOwner` relies on for `zitadel:issuer`.
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 owner, invited by ZITADEL email once the org's ZITADEL org
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 planOwner(org, recordedIds, authReady) {
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 this step stays silent rather than
420
+ // platform's sign-in as blocked, so these steps stay silent rather than
415
421
  // repeating it.
416
422
  if (!authReady)
417
- return null;
418
- return {
419
- action: {
420
- system: 'zitadel',
421
- kind: 'owner',
422
- key: org.id,
423
- desired: {
424
- email: org.owner.email,
425
- givenName: org.owner.givenName,
426
- familyName: org.owner.familyName,
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
- step: {
430
- description: `invite ${org.slug}'s owner by email`,
431
- destroys: false,
432
- migrates: false,
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 `planOwner` above uses and for the same
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 `planOwner`, a re-run is not simply "already done or not": the
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 `planOwner` (not after mail, chat, or any hosting
459
- * step), for the same reason `planOwner`'s own doc comment gives: on a
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 `planOwner` follows.
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
- const mailboxAlreadyGiven = Boolean(recordedIds[MAIL_OWNER_MAILBOX_SCOPE_KEY]);
587
- const hasOwner = Boolean(org.owner);
588
- const ownerMailboxAddress = org.mail.ownerMailboxAddress ?? null;
589
- const mailboxBlocked = hasOwner && !mailboxAlreadyGiven && !ownerMailboxAddress
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 = hasOwner && !mailboxAlreadyGiven && ownerMailboxAddress !== null;
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 ? `give ${org.slug}'s owner a mailbox` : '';
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
- // Chat off (not in v1): plan nothing, and leave any live chat unclaimed so
719
- // it is reported in `unmanaged` and never adopted or destroyed.
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 null;
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
- description: `provision chat for ${org.slug} on a wtfalch server_name`,
739
- destroys: false,
740
- migrates: false,
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 null;
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
- description: `re-key ${org.slug}'s chat onto ${domain}, copying rooms and messages so history reads continuous`,
753
- destroys: true,
754
- migrates: true,
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
- description: `reverse ${org.slug}'s chat re-key back onto its original server_name — not yet built (#7 amendment 3)`,
766
- destroys: true,
767
- migrates: true,
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 planOwner} uses and for the same reason: ai's `issueOrganisationKey`
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 planOwner} uses for inviting a human
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 `planOwner`. Once
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 `planOwner`: Cloudflare's live listing has no Access apps. A later
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
+ };