@wtfalch/foundry 0.6.0 → 0.8.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.
Files changed (58) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +12 -8
  2. package/dist/bootstrap/bootstrap.js +4 -12
  3. package/dist/bootstrap/declare.d.ts +3 -1
  4. package/dist/bootstrap/declare.js +6 -2
  5. package/dist/bootstrap/versions.d.ts +25 -0
  6. package/dist/bootstrap/versions.js +43 -0
  7. package/dist/calls-sfu-deploy-support.d.ts +1 -1
  8. package/dist/calls-sfu-deploy-support.js +1 -1
  9. package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
  10. package/dist/calls-sfu-ssh-adapter.js +1 -1
  11. package/dist/calls-sfu.d.ts +8 -6
  12. package/dist/calls-sfu.js +9 -6
  13. package/dist/index.d.ts +3 -4
  14. package/dist/index.js +5 -2
  15. package/dist/migrations/0022_chat_enabled.sql +6 -0
  16. package/dist/migrations/0023_hosting_app_env.sql +8 -0
  17. package/dist/migrations/0024_audit_events.sql +211 -0
  18. package/dist/migrations/0025_audit_display.sql +147 -0
  19. package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
  20. package/dist/migrations/0027_audit_chain.sql +170 -0
  21. package/dist/migrations/0028_audit_rls.sql +111 -0
  22. package/dist/migrations-dir.d.ts +9 -0
  23. package/dist/migrations-dir.js +10 -0
  24. package/dist/org.d.ts +24 -0
  25. package/dist/org.js +13 -0
  26. package/dist/plan/agora.d.ts +21 -0
  27. package/dist/plan/agora.js +23 -0
  28. package/dist/plan/calls-matrix.d.ts +4 -2
  29. package/dist/plan/calls-matrix.js +5 -2
  30. package/dist/plan/cloudflared-connector.d.ts +3 -2
  31. package/dist/plan/cloudflared-connector.js +5 -3
  32. package/dist/plan/diff.d.ts +18 -3
  33. package/dist/plan/diff.js +369 -42
  34. package/dist/plan/executor.d.ts +378 -44
  35. package/dist/plan/executor.js +816 -57
  36. package/dist/plan/index.d.ts +2 -0
  37. package/dist/plan/index.js +1 -0
  38. package/dist/plan/login-host.d.ts +88 -0
  39. package/dist/plan/login-host.js +73 -0
  40. package/dist/plan/plan-apply.d.ts +23 -8
  41. package/dist/plan/plan-apply.js +30 -10
  42. package/dist/plan/platform-diff.d.ts +102 -2
  43. package/dist/plan/platform-diff.js +232 -1
  44. package/dist/plan/platform-providers.d.ts +37 -2
  45. package/dist/plan/platform-providers.js +223 -36
  46. package/dist/plan/providers-readers.js +44 -1
  47. package/dist/plan.d.ts +43 -0
  48. package/dist/plan.js +16 -1
  49. package/dist/store/audit.d.ts +36 -4
  50. package/dist/store/audit.js +110 -29
  51. package/dist/store/postgres.d.ts +14 -0
  52. package/dist/store/postgres.js +17 -9
  53. package/dist/store/schema.d.ts +37 -1
  54. package/dist/store/schema.js +7 -0
  55. package/package.json +10 -5
  56. package/versions.json +8 -0
  57. package/dist/migrate.d.ts +0 -38
  58. package/dist/migrate.js +0 -47
@@ -24,6 +24,8 @@ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCop
24
24
  export type { ScopeResolver, RecordedIds } from './providers-readers.js';
25
25
  export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
26
26
  export type { AgoraHostPort, AgoraHostValues } from './agora.js';
27
+ export type { LoginHostBinding, LoginHostBindingsPort } from './login-host.js';
28
+ export { loginHostnameFor } from './login-host.js';
27
29
  export type { MailIdentityApp, MailIdentityAudienceCheck } from './mail-identity.js';
28
30
  export { checkMailIdentityAudience } from './mail-identity.js';
29
31
  export type { MailAllowlistApp } from './mail-identity-allowlist.js';
@@ -12,6 +12,7 @@ export { createZitadelAdminMinter } from './zitadel-machine-user.js';
12
12
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
13
13
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
14
14
  export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
15
+ export { loginHostnameFor } from './login-host.js';
15
16
  export { checkMailIdentityAudience } from './mail-identity.js';
16
17
  export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
17
18
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
@@ -0,0 +1,88 @@
1
+ import type { DeclaredOrg } from '../org.js';
2
+ /**
3
+ * #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
4
+ * need to agree on for an org's own company login host -- the hostname and
5
+ * the shape of zitadel-login's own routing entry -- the same split
6
+ * `agora.ts` already makes for Agora's own hostname and env.
7
+ *
8
+ * Decided in https://github.com/wtfalch/auth/issues/137 (William,
9
+ * 2026-10-01): foundry owns company sign-in setup through
10
+ * `@wtfalch/providers`, not `auth`'s own `scripts/namespace-provisioner.mjs`.
11
+ * `auth/docs/company-login-hosts.md` is the enforcement side this hostname
12
+ * and binding feed.
13
+ */
14
+ /**
15
+ * `auth.<domain>` once the org has its own domain and `issuer.usesDomain` is on, `<slug>-auth.oikos.host`
16
+ * otherwise -- the same per-tier shape `agora.ts`'s own `agoraHostnameFor`
17
+ * uses for Agora, and the two tiers #267's issue names directly
18
+ * ("auth.<domain>" on the org's own domain, "<slug>-auth.oikos.host" for
19
+ * tier 1, riding the shared `*.oikos.host` wildcard tunnel the same way
20
+ * `<slug>-agora.oikos.host` does). `../slug.ts`'s
21
+ * `RESERVED_AUTH_AND_ENVIRONMENTS` already reserves the `auth` *slug* so no
22
+ * org's own `<slug>.oikos.host` can collide with this subdomain, the same
23
+ * way `AGORA_SUFFIX` reserves `-agora`.
24
+ */
25
+ export declare function loginHostnameFor(org: DeclaredOrg): string;
26
+ /**
27
+ * One entry in zitadel-login's own `LOGIN_HOST_BINDINGS`
28
+ * (`auth/docs/company-login-hosts.md`) -- a single-line JSON document naming
29
+ * every company-bound host, its ZITADEL organization and the client ids
30
+ * allowed to use it. `host` is {@link loginHostnameFor}'s own value;
31
+ * `organizationId` is the org's ZITADEL org id (`executor.ts`'s
32
+ * `zitadelIssuer` recorded it as `zitadel_org`).
33
+ */
34
+ export interface LoginHostBinding {
35
+ host: string;
36
+ organizationId: string;
37
+ clientIds: string[];
38
+ }
39
+ /**
40
+ * Host-supplied write access to zitadel-login's own `LOGIN_HOST_BINDINGS`
41
+ * env (`auth/docs/company-login-hosts.md`'s own "Set `LOGIN_HOST_BINDINGS`
42
+ * on zitadel-login", "today set by hand"). Optional on
43
+ * {@link FoundryStepExecutorPorts} the same way `jobsScheduler`/`mailCopy`
44
+ * are, but without it the login-host step is blocked, not partly applied:
45
+ * isolation between orgs rests on the binding, and an app repointed at a host
46
+ * with no binding cannot sign in. Wiring the port means the host routes to
47
+ * zitadel-login (DNS and ingress), because the first apply after wiring
48
+ * repoints the org's bound apps.
49
+ */
50
+ export interface LoginHostBindingsPort {
51
+ upsertBinding(binding: LoginHostBinding): Promise<void>;
52
+ /**
53
+ * #274: removes a host's entry from zitadel-login's own `LOGIN_HOST_BINDINGS`
54
+ * -- the old host's binding, once a host change or an org destroy has
55
+ * moved or removed what it pointed at. Treat "not found" as done, the same
56
+ * rule `executor.ts`'s `isNotFoundError` gives every other removal in this
57
+ * package.
58
+ */
59
+ deleteBinding(host: string): Promise<void>;
60
+ }
61
+ /**
62
+ * Scope id holding what the binding was last written with
63
+ * ({@link loginBindingState}). Client ids are not secrets. The planner
64
+ * replans the login-host step when the org's recorded client ids no longer
65
+ * match it, so an app created after the first apply is added to the binding
66
+ * on the next run.
67
+ */
68
+ export declare const LOGIN_BINDING_SCOPE_KEY = "login_host_binding";
69
+ /** The org's recorded app client ids (hosted app, cms, agora), sorted. */
70
+ export declare function loginHostClientIds(recorded: Readonly<Record<string, string>>): string[];
71
+ /** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
72
+ export declare function loginBindingState(host: string, clientIds: readonly string[]): string;
73
+ /**
74
+ * #274 fix round 2: hosts a company-login-host change (or an org destroy)
75
+ * tried to remove and could not yet -- some live app's own ZITADEL Login V2
76
+ * base URI still pointed at one of them, so removing it would have broken
77
+ * that app's sign-in. `zitadelLoginHost` always advances
78
+ * `zitadel:login-host:<key>` to the org's *current* desired host on every
79
+ * successful run (never leaving it on an old one), so this is the only
80
+ * record of a removal still owed -- lost otherwise the moment a second host
81
+ * change moves `previousHost` on. Comma-joined, sorted, the same shape
82
+ * `MAIL_ROLE_MAILBOXES_SCOPE_KEY` (`executor.ts`) holds a list in.
83
+ */
84
+ export declare const LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY = "zitadel_login_host_pending_removal";
85
+ /** The hosts {@link LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY} currently names, parsed back out. */
86
+ export declare function pendingRemovalHosts(recorded: Readonly<Record<string, string>>): string[];
87
+ /** {@link pendingRemovalHosts}' own serialisation, sorted so the recorded value is stable regardless of check order. */
88
+ export declare function pendingRemovalState(hosts: readonly string[]): string;
@@ -0,0 +1,73 @@
1
+ import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, } from './agora.js';
2
+ /**
3
+ * #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
4
+ * need to agree on for an org's own company login host -- the hostname and
5
+ * the shape of zitadel-login's own routing entry -- the same split
6
+ * `agora.ts` already makes for Agora's own hostname and env.
7
+ *
8
+ * Decided in https://github.com/wtfalch/auth/issues/137 (William,
9
+ * 2026-10-01): foundry owns company sign-in setup through
10
+ * `@wtfalch/providers`, not `auth`'s own `scripts/namespace-provisioner.mjs`.
11
+ * `auth/docs/company-login-hosts.md` is the enforcement side this hostname
12
+ * and binding feed.
13
+ */
14
+ /**
15
+ * `auth.<domain>` once the org has its own domain and `issuer.usesDomain` is on, `<slug>-auth.oikos.host`
16
+ * otherwise -- the same per-tier shape `agora.ts`'s own `agoraHostnameFor`
17
+ * uses for Agora, and the two tiers #267's issue names directly
18
+ * ("auth.<domain>" on the org's own domain, "<slug>-auth.oikos.host" for
19
+ * tier 1, riding the shared `*.oikos.host` wildcard tunnel the same way
20
+ * `<slug>-agora.oikos.host` does). `../slug.ts`'s
21
+ * `RESERVED_AUTH_AND_ENVIRONMENTS` already reserves the `auth` *slug* so no
22
+ * org's own `<slug>.oikos.host` can collide with this subdomain, the same
23
+ * way `AGORA_SUFFIX` reserves `-agora`.
24
+ */
25
+ export function loginHostnameFor(org) {
26
+ // `issuer.usesDomain` is the issuer's opt-in into the org's domain
27
+ // (`org.ts`'s `IssuerConfig`): a domain alone never moves it, so a domain
28
+ // with `usesDomain` false stays on the tier 1 host.
29
+ return org.domain && org.issuer.usesDomain ? `auth.${org.domain}` : `${org.slug}-auth.oikos.host`;
30
+ }
31
+ /**
32
+ * Scope id holding what the binding was last written with
33
+ * ({@link loginBindingState}). Client ids are not secrets. The planner
34
+ * replans the login-host step when the org's recorded client ids no longer
35
+ * match it, so an app created after the first apply is added to the binding
36
+ * on the next run.
37
+ */
38
+ export const LOGIN_BINDING_SCOPE_KEY = 'login_host_binding';
39
+ /** The org's recorded app client ids (hosted app, cms, agora), sorted. */
40
+ export function loginHostClientIds(recorded) {
41
+ return [
42
+ recorded[APP_CLIENT_ID_SCOPE_KEY],
43
+ recorded[CMS_CLIENT_ID_SCOPE_KEY],
44
+ recorded[AGORA_CLIENT_ID_SCOPE_KEY],
45
+ ]
46
+ .filter((id) => Boolean(id))
47
+ .sort();
48
+ }
49
+ /** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
50
+ export function loginBindingState(host, clientIds) {
51
+ return `${host} ${clientIds.join(',')}`;
52
+ }
53
+ /**
54
+ * #274 fix round 2: hosts a company-login-host change (or an org destroy)
55
+ * tried to remove and could not yet -- some live app's own ZITADEL Login V2
56
+ * base URI still pointed at one of them, so removing it would have broken
57
+ * that app's sign-in. `zitadelLoginHost` always advances
58
+ * `zitadel:login-host:<key>` to the org's *current* desired host on every
59
+ * successful run (never leaving it on an old one), so this is the only
60
+ * record of a removal still owed -- lost otherwise the moment a second host
61
+ * change moves `previousHost` on. Comma-joined, sorted, the same shape
62
+ * `MAIL_ROLE_MAILBOXES_SCOPE_KEY` (`executor.ts`) holds a list in.
63
+ */
64
+ export const LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY = 'zitadel_login_host_pending_removal';
65
+ /** The hosts {@link LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY} currently names, parsed back out. */
66
+ export function pendingRemovalHosts(recorded) {
67
+ const raw = recorded[LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY];
68
+ return raw ? raw.split(',').filter((host) => host.length > 0) : [];
69
+ }
70
+ /** {@link pendingRemovalHosts}' own serialisation, sorted so the recorded value is stable regardless of check order. */
71
+ export function pendingRemovalState(hosts) {
72
+ return [...hosts].sort().join(',');
73
+ }
@@ -17,14 +17,19 @@ export interface AuditActor {
17
17
  /**
18
18
  * The seam onto `@wtfalch/audit`.
19
19
  *
20
- * #19 asks for `@wtfalch/audit` itself, but that package's `record`/`ledger`
21
- * (see `app-template/src/lib/authz/audit.ts`) is bound to one app's Drizzle
22
- * table, session model and closed `EventName`/`ActorClass` vocabulary — none
23
- * of which foundry's SDK package has (it has no database of its own; #18
24
- * owns the store). Adopting it here is more than a small change, so this is
25
- * the interface instead, logged as a Finding on #19 rather than a half
26
- * adoption. A real implementation binds `@wtfalch/audit`'s `ledger.sign` to
27
- * foundry's own vocabulary and table once foundry has one.
20
+ * #19 asked for `@wtfalch/audit` itself, but at the time that package's
21
+ * `record`/`ledger` were bound to one app's Drizzle table, session model and
22
+ * closed `EventName`/`ActorClass` vocabulary -- none of which foundry's SDK
23
+ * package had. This interface was the stand-in, logged as a Finding on #19
24
+ * rather than a half adoption.
25
+ *
26
+ * #232: `@wtfalch/audit` now supports a service with no tenant database of
27
+ * its own (`ledgerVocabulary`, `tenantId: null`, the org as `target`) -- see
28
+ * `store/audit.ts`'s `createPostgresAuditLog`, which binds `ledger.sign` to
29
+ * foundry's own closed vocabulary and `foundry_audit_events`' replacement,
30
+ * `audit_events` (`@wtfalch/audit`'s own table, through migrations
31
+ * 0022-0026). This interface itself is unchanged -- `bootstrap.ts` and
32
+ * `redactingAudit` need no change either.
28
33
  */
29
34
  export interface AuditLog {
30
35
  recordPlan(entry: {
@@ -187,6 +192,8 @@ export interface PlatformStepExecutor {
187
192
  ai?: string;
188
193
  archon?: string;
189
194
  mail?: string;
195
+ /** #257: see `PlanPlatformOptions.blocked.backupExecutorMissing` (`platform-diff.ts`). */
196
+ backupExecutorMissing?: boolean;
190
197
  }>;
191
198
  /**
192
199
  * Steps whose live state differs from the declared one in a way the recorded
@@ -281,6 +288,13 @@ export interface FoundryPlanApplyOptions {
281
288
  * step never runs (and fails) where no scheduler is deployed.
282
289
  */
283
290
  jobsScheduler?: boolean;
291
+ /**
292
+ * #267: true when the host's executor was built with a `loginHostBindings`
293
+ * port. Optional, and false without it: each org's login host is then
294
+ * reported blocked instead of planned, so nothing is trusted or repointed
295
+ * before zitadel-login's binding can be written.
296
+ */
297
+ loginHostBindings?: boolean;
284
298
  /**
285
299
  * #177: true when the host's executor was built with its own `filesOrganisation`
286
300
  * / `aiOrganisation` port. Without one, the org step needs the platform's
@@ -334,6 +348,7 @@ export declare class FoundryPlanApply implements PlanApply {
334
348
  private readonly actor;
335
349
  private readonly agora;
336
350
  private readonly jobsScheduler;
351
+ private readonly loginHostBindings;
337
352
  private readonly hostFiles;
338
353
  private readonly hostAi;
339
354
  private readonly now;
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
3
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
3
4
  import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
4
5
  import { readLivePlatformResources, readLiveResources, } from './live.js';
@@ -78,6 +79,7 @@ export class FoundryPlanApply {
78
79
  actor;
79
80
  agora;
80
81
  jobsScheduler;
82
+ loginHostBindings;
81
83
  hostFiles;
82
84
  hostAi;
83
85
  now;
@@ -92,6 +94,7 @@ export class FoundryPlanApply {
92
94
  this.actor = options.actor;
93
95
  this.agora = options.agora;
94
96
  this.jobsScheduler = options.jobsScheduler ?? false;
97
+ this.loginHostBindings = options.loginHostBindings ?? false;
95
98
  this.hostFiles = options.filesOrganisation ?? false;
96
99
  this.hostAi = options.aiOrganisation ?? false;
97
100
  this.now = options.now ?? (() => new Date());
@@ -351,7 +354,7 @@ export class FoundryPlanApply {
351
354
  this.orgPlatformReadiness(),
352
355
  this.orgAgoraHost(id),
353
356
  ]);
354
- const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness);
357
+ const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, { loginHostBindings: this.loginHostBindings });
355
358
  return { org, plan: toPlan(detail), detail };
356
359
  }
357
360
  /**
@@ -426,6 +429,15 @@ export class FoundryPlanApply {
426
429
  async sfuBlockedReason(platform) {
427
430
  if (!platform.sfu.enabled)
428
431
  return null;
432
+ // #248: both images the SFU compose actually runs, checked before the
433
+ // connection -- a floating tag is a reason to block independent of
434
+ // whether a connection is held.
435
+ const livekitBlocked = floatingTagBlocked('LiveKit', VERSIONS.livekit, 'v1.13.7');
436
+ if (livekitBlocked)
437
+ return `the platform SFU is blocked, not planned: ${livekitBlocked}`;
438
+ const cloudflaredBlocked = floatingTagBlocked('cloudflared', VERSIONS.cloudflared, '2026.9.3');
439
+ if (cloudflaredBlocked)
440
+ return `the platform SFU is blocked, not planned: ${cloudflaredBlocked}`;
429
441
  const port = this.requirePlatformDeps().sfuConnection;
430
442
  if (!port) {
431
443
  return 'the platform SFU is blocked, not planned: the host supplies no SFU connection port (#170)';
@@ -510,7 +522,10 @@ const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
510
522
  /**
511
523
  * Every resource `FoundryPlanApply.destroy` tears down, built from
512
524
  * `AppliedState.serviceIds`, in the order the goal line and this campaign's
513
- * report settle on: the Cloudflare tunnel first, then the Access gate (stop routing traffic to the
525
+ * report settle on: the org's own company login host first (#274 -- an
526
+ * instance-wide ZITADEL trust and a zitadel-login binding, neither depending
527
+ * on anything else this org owns, so nothing is lost by going first), then
528
+ * the Cloudflare tunnel, then the Access gate (stop routing traffic to the
514
529
  * app before tearing it down), then the org's own Coolify database and app,
515
530
  * then the org's customers ZITADEL org (plan gap A9), then every ZITADEL app
516
531
  * foundry recorded, then the ZITADEL project, then the
@@ -539,6 +554,7 @@ function destroyTargets(serviceIds) {
539
554
  // collected in this order, then every ZITADEL app (there can be several,
540
555
  // #71), then the two scope ids last.
541
556
  const ORDERED_PREFIXES = [
557
+ 'zitadel:login-host:',
542
558
  'cloudflare:tunnel:',
543
559
  'cloudflare:access:',
544
560
  'coolify:database:',
@@ -617,12 +633,13 @@ function stepField(action) {
617
633
  *
618
634
  * Three sources, in this order:
619
635
  * - fields no planner step exists for yet, so no apply can make them true:
620
- * SSO, guests and solo when turned on, a bespoke app repo, an adopted app
621
- * in the `repo` form (#7's `#15` comment: "declared but refused by
622
- * `apply`"), and calls or hosting on a domain the org has not recorded
623
- * (#145: calls on the org's own domain now has a planner step, the same
624
- * way hosting's own domain does, so it only fails for that one missing-
625
- * domain reason, not unconditionally);
636
+ * SSO, guests and solo when turned on, an adopted app in the `repo` form
637
+ * (#7's `#15` comment: "declared but refused by `apply`"), and calls or
638
+ * hosting on a domain the org has not recorded (#145: calls on the org's
639
+ * own domain now has a planner step, the same way hosting's own domain
640
+ * does, so it only fails for that one missing-domain reason, not
641
+ * unconditionally). `bespokeAppRepo` is not one of these: `coolify:app`
642
+ * reads it (#240), so it converges with that step like any other field;
626
643
  * - chat's reverse re-key, which the planner surfaces but which is not built
627
644
  * (#7 amendment 3), so it counts as failed even when no step threw;
628
645
  * - on a failed apply, the field of every planned step from the one that
@@ -646,8 +663,6 @@ function failedFields(org, detail, completed, serviceIds) {
646
663
  failed.add('guests.enabled');
647
664
  if (org.solo.enabled)
648
665
  failed.add('solo.enabled');
649
- if (org.bespokeAppRepo !== null)
650
- failed.add('bespokeAppRepo');
651
666
  if (org.adoptedApp?.form === 'repo')
652
667
  failed.add('adoptedApp');
653
668
  // `planCallsService`/`planTunnel`/`planZone` all share this same domain
@@ -688,6 +703,11 @@ function canonical(plan) {
688
703
  orgId: plan.orgId,
689
704
  steps: [...plan.steps].sort((x, y) => x.description.localeCompare(y.description)),
690
705
  unmanaged: [...plan.unmanaged].sort((x, y) => x.description.localeCompare(y.description)),
706
+ // #251: display-only, but still part of what a plan says -- live state
707
+ // moving between `plan()` and `apply()` in a way that only changes a
708
+ // version-change line must still be caught as stale, the same as any
709
+ // other field here.
710
+ versionChanges: [...(plan.versionChanges ?? [])].sort((x, y) => x.description.localeCompare(y.description)),
691
711
  };
692
712
  }
693
713
  /**
@@ -1,5 +1,5 @@
1
- import type { OrgId } from '../org.js';
2
- import type { Plan, UnmanagedItem } from '../plan.js';
1
+ import type { DeclaredOrg, OrgId } from '../org.js';
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';
5
5
  import type { LiveResource } from './live.js';
@@ -14,6 +14,8 @@ import type { LiveResource } from './live.js';
14
14
  export interface PlatformPlanDetail {
15
15
  plannedSteps: PlannedStep[];
16
16
  unmanaged: UnmanagedItem[];
17
+ /** #251: see {@link VersionChangeItem}. Never folded into `plannedSteps`. */
18
+ versionChanges: VersionChangeItem[];
17
19
  }
18
20
  /**
19
21
  * The `Plan` (#19's public contract) a {@link PlatformPlanDetail} carries,
@@ -33,6 +35,47 @@ export declare function toPlatformPlan(detail: PlatformPlanDetail, ownerOrgId: O
33
35
  * an org's own (which is keyed by that org's id).
34
36
  */
35
37
  export declare const PLATFORM_AUTH_KEY = "platform:auth";
38
+ /**
39
+ * #251: the ZITADEL image tag the last successful auth run actually set
40
+ * live. `provisionPlatformAuth` records this only when it set the tag --
41
+ * today that is creation alone, `findOrCreatePlatformApp`'s own
42
+ * `justCreated`, since nothing yet patches `docker_registry_image_tag` on
43
+ * an app found already running (unlike mail, which does). Recording it on
44
+ * every run regardless, the way the app id itself is recorded, would make
45
+ * {@link versionNotAppliedLine} compare the pinned version against a value
46
+ * that was never actually applied.
47
+ */
48
+ export declare const PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY = "auth_zitadel_applied_image_tag";
49
+ /**
50
+ * #251: the Synapse/MAS image tags an org's calls Matrix stack last
51
+ * successful apply actually set live. `coolifyCalls` (`executor.ts`)
52
+ * records these only at creation, for the same reason
53
+ * {@link PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY} is: nothing yet
54
+ * patches either tag on an app `findOrCreateCallsApp` found already
55
+ * running.
56
+ */
57
+ export declare const CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY = "calls_synapse_applied_image_tag";
58
+ export declare const CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY = "calls_mas_applied_image_tag";
59
+ /**
60
+ * One line for a service whose own step genuinely redeploys when its
61
+ * recorded applied ref differs from the declared or pinned one (mail,
62
+ * archon, ai, files, integrations) -- `from` undefined (nothing applied
63
+ * yet, a first-ever apply) or already equal to `to` means nothing to
64
+ * report.
65
+ */
66
+ export declare function versionChangeLine(service: string, from: string | undefined, to: string, note: string): VersionChangeItem | null;
67
+ /**
68
+ * One line for a service whose own step never patches a running app's
69
+ * image tag once created -- zitadel, synapse, mas. #251 round 3: a prior
70
+ * attempt worded this line the same as {@link versionChangeLine}'s
71
+ * "X -> Y (image, redeploy)", which promised a redeploy `apply` can never
72
+ * actually perform for these three. `appLive` false means nothing live yet
73
+ * to compare against (no line, the same first-apply case
74
+ * {@link versionChangeLine} has); a live app with no recorded `applied`
75
+ * value predates this tracking and is reported as `running tag unknown`
76
+ * rather than silently showing nothing.
77
+ */
78
+ export declare function versionNotAppliedLine(service: string, appLive: boolean, applied: string | undefined, pinned: string): VersionChangeItem | null;
36
79
  /**
37
80
  * Scope keys (colon-free, so never mistaken for a `system:kind:key` resource
38
81
  * id) for foundry's ZITADEL admin machine user. The delivered key is the guard
@@ -268,6 +311,28 @@ export declare function platformMailMxHost(apex: string): string;
268
311
  */
269
312
  export declare const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = "mail_applied_apex";
270
313
  export declare const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = "mail_applied_host";
314
+ /**
315
+ * #243: the org's `hosting.appEnv` values the last `coolify:app` apply pushed,
316
+ * recorded as one JSON object (env name to value; ids and a domain, never a
317
+ * secret). `planApp` plans the step again when {@link resolveAppEnv} differs
318
+ * from it, so a changed map reaches an app that is already live.
319
+ */
320
+ export declare const APP_ENV_APPLIED_SCOPE_KEY = "app_env_applied";
321
+ /**
322
+ * #239: set to '1' once an app has been restarted after its database URLs
323
+ * were pushed. Not a URL. Without it a crash between the push and the restart
324
+ * would leave a stale container that no plan notices.
325
+ */
326
+ export declare const DB_ENV_APPLIED_SCOPE_KEY = "db_env_applied";
327
+ /**
328
+ * The `hosting.appEnv` entries whose source has a recorded value, in env-name
329
+ * order, and the env names whose source has none yet. Shared by `planApp` and
330
+ * the executor's `pushAppEnv` so both read the same recorded ids.
331
+ */
332
+ export declare function resolveAppEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): {
333
+ values: Record<string, string>;
334
+ unresolved: Array<[string, string]>;
335
+ };
271
336
  /**
272
337
  * #172: the resource key `planAi` plans under and `FoundryPlatformStepExecutor`
273
338
  * dispatches on, the same `platform:<service>` shape as
@@ -419,6 +484,13 @@ export interface PlanPlatformOptions {
419
484
  ai?: string;
420
485
  archon?: string;
421
486
  mail?: string;
487
+ /**
488
+ * #257: true once a service's database exists but the host built the
489
+ * platform executor with no backup dependencies -- every such service's
490
+ * `coolify:backup:<service>` step is blocked, not planned, the same
491
+ * "executor missing" shape {@link authExecutorMissing} has for auth.
492
+ */
493
+ backupExecutorMissing?: boolean;
422
494
  };
423
495
  /**
424
496
  * Steps whose live state differs from the declared one in a way the recorded
@@ -464,6 +536,34 @@ export declare function planPlatform(platform: DeclaredPlatform, live: LiveResou
464
536
  * it, so the planner and the executor/reader can never key it apart).
465
537
  */
466
538
  export declare const JOBS_SCHEDULER_KEY = "jobs";
539
+ /**
540
+ * #257: the six platform databases foundry itself creates, each keyed by
541
+ * the scope id `platform-providers.ts`'s own `createPostgresDatabase` call
542
+ * site records once that service's database exists --
543
+ * `JOBS_DATABASE_SCOPE_KEY`, `PLATFORM_AUTH_DB_SCOPE_KEY`,
544
+ * `PLATFORM_ARCHON_DB_SCOPE_KEY`, `PLATFORM_AI_DB_SCOPE_KEY`,
545
+ * `PLATFORM_FILES_DB_SCOPE_KEY`, `INTEGRATIONS_DATABASE_SCOPE_KEY`. Those
546
+ * constants stay module-private in `platform-providers.ts`, grouped with
547
+ * each service's other scope keys; the string values here are a second,
548
+ * documented copy rather than an import, so `planBackups` below never
549
+ * reaches into that file and `platform-providers.ts` keeps importing *from*
550
+ * this one, never the other way (every other scope key constant already
551
+ * flows that direction). `platform-providers.test.ts`'s own
552
+ * `coolify:backup` suite exercises both the executor's real constants and
553
+ * this planner end to end, so a renamed key on either side shows up as a
554
+ * database the planner can no longer see -- reported blocked, per rule 2,
555
+ * never a wrong step.
556
+ */
557
+ export declare const PLATFORM_BACKUP_DATABASE_SCOPE_KEYS: {
558
+ readonly jobs: "coolify_jobs_database";
559
+ readonly auth: "coolify_platform_auth_database";
560
+ readonly archon: "coolify_platform_archon_database";
561
+ readonly ai: "coolify_platform_ai_database";
562
+ readonly files: "coolify_platform_files_database";
563
+ readonly integrations: "coolify_integrations_database";
564
+ };
565
+ export type PlatformBackupServiceKey = keyof typeof PLATFORM_BACKUP_DATABASE_SCOPE_KEYS;
566
+ export declare const PLATFORM_BACKUP_SERVICE_KEYS: readonly PlatformBackupServiceKey[];
467
567
  /**
468
568
  * #174: the resource key the integrations host is planned and recorded under,
469
569
  * `platform:integrations`, like {@link PLATFORM_AUTH_KEY}: the step is