@wtfalch/foundry 0.39.6 → 0.41.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/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EntryGate, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceSchema, ServicesConfig, VersionPin, VersionPinPart, } from './org.js';
2
- export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateVersionPin, validateSite, } from './org.js';
1
+ export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceSchema, ServicesConfig, VersionPin, VersionPinPart, } from './org.js';
2
+ export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateVersionPin, validateSite, } from './org.js';
3
3
  export type { DeclaredPlatform, PlatformService, PlatformServiceKey, PlatformSfuService, HeartbeatsProvider, } from './platform.js';
4
4
  export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, validatePlatform, } from './platform.js';
5
5
  import { type PinnedVersions } from './bootstrap/versions.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateVersionPin, validateSite, } from './org.js';
1
+ export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateVersionPin, validateSite, } from './org.js';
2
2
  export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, validatePlatform, } from './platform.js';
3
3
  // archon#273: the pinned platform service versions the planner deploys, so a
4
4
  // host can show declared against running. Readonly: it is the planner's own
package/dist/org.d.ts CHANGED
@@ -323,6 +323,12 @@ export interface HostingEntry {
323
323
  * restricted.
324
324
  */
325
325
  capabilities?: 'restricted' | 'standard';
326
+ /**
327
+ * Named volumes that survive a redeploy, each mounted at an absolute path in the app container. The app step
328
+ * creates each one once, found by mount path, and restarts an app that was already running. An apply never
329
+ * removes one. Absent means none besides the log volume.
330
+ */
331
+ volumes?: EntryVolume[];
326
332
  /** Deploy on every push to `branch`. A host should declare `false`; foundry creates every other app with auto-deploy off. */
327
333
  autoDeploy: boolean;
328
334
  /**
@@ -350,6 +356,15 @@ export interface HostingEntry {
350
356
  */
351
357
  worker?: WorkerConfig;
352
358
  }
359
+ /** One named volume on a hosting entry's app. */
360
+ export interface EntryVolume {
361
+ /** A DNS label, unique per entry. The volume is named `<app name>-<name>`. */
362
+ name: string;
363
+ /** An absolute path in the container. Never `/app/logs`, which the log volume holds. */
364
+ mountPath: string;
365
+ }
366
+ /** The most volumes one entry may declare. */
367
+ export declare const MAX_ENTRY_VOLUMES = 8;
353
368
  /** One Cloudflare Access gate over some of an entry's `hostnames`. */
354
369
  export interface EntryGate {
355
370
  /** A DNS label, unique in the org. It names the plan step and the edge gate. */
@@ -472,8 +487,8 @@ export declare const EDGE_ENTRY_ENV_NAMES: readonly ["EDGE_URL", "EDGE_API_KEY"]
472
487
  * looks like a secret. `{ from }` names a value the plan already mints.
473
488
  */
474
489
  export type EnvValue = string | {
475
- from: Exclude<AppEnvSource, 'sharedSecret'>;
476
- } | EnvSharedSecret;
490
+ from: Exclude<AppEnvSource, 'sharedSecret' | 'heldSecret'>;
491
+ } | EnvSharedSecret | EnvHeldSecret;
477
492
  /**
478
493
  * A secret foundry generates once and holds under an org-scoped ref. Every
479
494
  * entry of the same org that names the same `name` gets the same value; an
@@ -484,6 +499,16 @@ export interface EnvSharedSecret {
484
499
  from: 'sharedSecret';
485
500
  name: string;
486
501
  }
502
+ /**
503
+ * A secret the operator holds, written to the entry's env only. `name` is the
504
+ * env name it is declared under. The operator puts the value under
505
+ * {@link heldEnvRef}; foundry never generates it, and it never reaches a plan,
506
+ * a log or an error.
507
+ */
508
+ export interface EnvHeldSecret {
509
+ from: 'heldSecret';
510
+ name: string;
511
+ }
487
512
  /** The org's `app` entry: today's one app. */
488
513
  export declare function appEntry(hosting: HostingConfig): HostingEntry | undefined;
489
514
  /**
@@ -493,9 +518,9 @@ export declare function appEntry(hosting: HostingConfig): HostingEntry | undefin
493
518
  * sends from. Each is read from `StepExecutionContext.recordedIds()` in
494
519
  * `executor.ts`'s `pushAppEnv`, never minted again there.
495
520
  */
496
- export type AppEnvSource = 'filesOrganisationId' | 'aiOrganisationId' | 'mailDomain' | 'sharedSecret';
521
+ export type AppEnvSource = 'filesOrganisationId' | 'aiOrganisationId' | 'mailDomain' | 'sharedSecret' | 'heldSecret';
497
522
  /**
498
- * `sharedSecret` is the one secret source: {@link EnvSharedSecret}. The
523
+ * `sharedSecret` ({@link EnvSharedSecret}) and `heldSecret` ({@link EnvHeldSecret}) are the secret sources. The
499
524
  * platform's Lettermint key is not a source any more (2026-10-11): each
500
525
  * company uses its own mail key.
501
526
  */
@@ -504,6 +529,13 @@ export declare const APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
504
529
  export declare const SECRET_APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
505
530
  /** The held-key ref of an org's shared secret. */
506
531
  export declare const sharedSecretRef: (orgId: string, name: string) => string;
532
+ /**
533
+ * The held-key ref of an entry's `heldSecret` env: where the operator puts the
534
+ * value. Throws on an input the declaration would not accept. The org id is a
535
+ * canonical lowercase UUID: the host stores `:` as `-`, so an id that may hold `-`
536
+ * would let two refs share one stored entry.
537
+ */
538
+ export declare const heldEnvRef: (orgId: string, entryKey: string, name: string) => string;
507
539
  /**
508
540
  * `null` when `hosting` is valid; otherwise the reason it is not (#71), same
509
541
  * shape as `validateBuyerIdentity` below.
package/dist/org.js CHANGED
@@ -8,6 +8,8 @@ export const ACCESS_RULE_KINDS = new Set(['bypass', 'service-token']);
8
8
  export const DEFAULT_GATE_LIMIT = 3;
9
9
  /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
10
10
  export const APP_ENTRY_KEY = 'app';
11
+ /** The most volumes one entry may declare. */
12
+ export const MAX_ENTRY_VOLUMES = 8;
11
13
  /** What an entry's ai key may hold: the most the org's own key holds. */
12
14
  export const AI_ENTRY_PERMISSIONS = ['ai.offerings:invoke', 'ai.runs:create'];
13
15
  /** The env names `uses.ai` writes; `env` may not name them. */
@@ -41,7 +43,7 @@ export function appEntry(hosting) {
41
43
  return hosting.entries.find((entry) => entry.key === APP_ENTRY_KEY);
42
44
  }
43
45
  /**
44
- * `sharedSecret` is the one secret source: {@link EnvSharedSecret}. The
46
+ * `sharedSecret` ({@link EnvSharedSecret}) and `heldSecret` ({@link EnvHeldSecret}) are the secret sources. The
45
47
  * platform's Lettermint key is not a source any more (2026-10-11): each
46
48
  * company uses its own mail key.
47
49
  */
@@ -50,11 +52,41 @@ export const APP_ENV_SOURCES = new Set([
50
52
  'aiOrganisationId',
51
53
  'mailDomain',
52
54
  'sharedSecret',
55
+ 'heldSecret',
53
56
  ]);
54
57
  /** The sources whose value is a secret: resolved at apply time, never recorded. */
55
- export const SECRET_APP_ENV_SOURCES = new Set(['sharedSecret']);
58
+ export const SECRET_APP_ENV_SOURCES = new Set([
59
+ 'sharedSecret',
60
+ 'heldSecret',
61
+ ]);
56
62
  /** The held-key ref of an org's shared secret. */
57
63
  export const sharedSecretRef = (orgId, name) => `foundry:shared-secret:${orgId}:${name}`;
64
+ const HELD_ENV_ORG_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
65
+ /** The host maps `:` to `-` in a stored id and stores at most 128 characters; the ref's fixed part is 52. */
66
+ const HELD_ENV_REF_MAX = 128;
67
+ const HELD_ENV_REF_FIXED = 52;
68
+ const HELD_ENV_ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
69
+ const HELD_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
70
+ /**
71
+ * The held-key ref of an entry's `heldSecret` env: where the operator puts the
72
+ * value. Throws on an input the declaration would not accept. The org id is a
73
+ * canonical lowercase UUID: the host stores `:` as `-`, so an id that may hold `-`
74
+ * would let two refs share one stored entry.
75
+ */
76
+ export const heldEnvRef = (orgId, entryKey, name) => {
77
+ if (!HELD_ENV_ORG_ID.test(orgId))
78
+ throw new Error('heldEnvRef: orgId is not a valid org id');
79
+ if (!HELD_ENV_ENTRY_KEY.test(entryKey)) {
80
+ throw new Error('heldEnvRef: entryKey must be a DNS label (lowercase letters, digits, hyphens)');
81
+ }
82
+ if (!HELD_ENV_NAME.test(name)) {
83
+ throw new Error('heldEnvRef: name must be an env var name (letters, digits, underscores)');
84
+ }
85
+ if (HELD_ENV_REF_FIXED + entryKey.length + name.length > HELD_ENV_REF_MAX) {
86
+ throw new Error(`heldEnvRef: the ref would be longer than ${HELD_ENV_REF_MAX} characters`);
87
+ }
88
+ return `org:${orgId}:entry:${entryKey}:env:${name}`;
89
+ };
58
90
  const ISSUER_APP_KINDS = new Set(['web', 'native', 'api']);
59
91
  const HOSTING_BUILD_PACKS = new Set([
60
92
  'dockerfile',
@@ -297,11 +329,30 @@ function validateEntryHostnames(hosting, names) {
297
329
  return null;
298
330
  }
299
331
  const ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
332
+ const VOLUME_MOUNT_PATH = /^(\/(?!\.\.?(\/|$))[A-Za-z0-9._-]+)+$/;
333
+ /** The log volume's mount path; `executor.ts`'s `LOG_DIR` is the same value. */
334
+ const LOG_DIR_PATH = '/app/logs';
335
+ /** Paths a volume would hide or nest under: the app's code, and the system roots a container needs. */
336
+ const VOLUME_FORBIDDEN_ROOTS = [
337
+ '/bin',
338
+ '/boot',
339
+ '/dev',
340
+ '/etc',
341
+ '/lib',
342
+ '/lib64',
343
+ '/proc',
344
+ '/run',
345
+ '/sbin',
346
+ '/sys',
347
+ '/usr',
348
+ '/var/run',
349
+ ];
350
+ const VOLUME_MOUNT_PATH_MAX = 255;
300
351
  const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
301
352
  // The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
302
353
  const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
303
354
  const SECRET_ENV_NAME = /_(KEY|SECRET|TOKEN|PASSWORD)$/;
304
- const HELD_SECRETS = 'held secrets are not available yet';
355
+ const HELD_SECRETS = "declare it as { from: 'heldSecret' }, not a plain value";
305
356
  /** The rules for one {@link HostingEntry}; `null` when it is valid. */
306
357
  function validateHostingEntry(entry, enabled) {
307
358
  const at = `hosting.entries '${entry.key}'`;
@@ -338,6 +389,9 @@ function validateHostingEntry(entry, enabled) {
338
389
  if (entry.database && !enabled) {
339
390
  return `${at} database requires hosting.enabled`;
340
391
  }
392
+ const invalidVolumes = validateEntryVolumes(entry, at);
393
+ if (invalidVolumes)
394
+ return invalidVolumes;
341
395
  for (const [name, value] of Object.entries(entry.env)) {
342
396
  if (!name) {
343
397
  return `${at} env needs a non-empty env var name`;
@@ -355,6 +409,14 @@ function validateHostingEntry(entry, enabled) {
355
409
  if (value.from === 'sharedSecret' && !ENTRY_KEY.test(value.name ?? '')) {
356
410
  return `${at} env '${name}' sharedSecret needs a name (lowercase letters, digits and hyphens)`;
357
411
  }
412
+ if (value.from === 'heldSecret') {
413
+ if (value.name !== name) {
414
+ return `${at} env '${name}' heldSecret name must be '${name}', the env name it is declared under`;
415
+ }
416
+ if (!HELD_ENV_NAME.test(name)) {
417
+ return `${at} env '${name}' heldSecret needs an env name of letters, digits and underscores`;
418
+ }
419
+ }
358
420
  }
359
421
  else if (SECRET_ENV_NAME.test(name) || (name.endsWith('_URL') && urlHasPassword(value))) {
360
422
  return `${at} env '${name}' looks like a secret: ${HELD_SECRETS}`;
@@ -362,6 +424,49 @@ function validateHostingEntry(entry, enabled) {
362
424
  }
363
425
  return null;
364
426
  }
427
+ /** `volumes`: a bounded list of named volumes at distinct absolute paths, none on the log volume's path or name,
428
+ * on `/app` or under a system path. */
429
+ function validateEntryVolumes(entry, at) {
430
+ const volumes = entry.volumes;
431
+ if (volumes === undefined)
432
+ return null;
433
+ if (!Array.isArray(volumes))
434
+ return `${at} volumes must be a list`;
435
+ if (volumes.length > MAX_ENTRY_VOLUMES) {
436
+ return `${at} volumes holds at most ${MAX_ENTRY_VOLUMES} volumes`;
437
+ }
438
+ const names = new Set();
439
+ const paths = new Set();
440
+ for (const volume of volumes) {
441
+ const { name, mountPath } = volume ?? {};
442
+ if (typeof name !== 'string' || !ENTRY_KEY.test(name)) {
443
+ return `${at} volumes needs a name that is a DNS label (lowercase letters, digits and hyphens, 1-63 characters)`;
444
+ }
445
+ if (name === 'logs') {
446
+ return `${at} volumes name 'logs' is the log volume's, which every app already has`;
447
+ }
448
+ if (names.has(name))
449
+ return `${at} volumes name '${name}' is not unique`;
450
+ names.add(name);
451
+ if (typeof mountPath !== 'string' || !VOLUME_MOUNT_PATH.test(mountPath)) {
452
+ return `${at} volumes '${name}' mountPath must be an absolute path of letters, digits, '.', '_', '-' and '/', with no '..', no empty part and no trailing '/'`;
453
+ }
454
+ if (mountPath.length > VOLUME_MOUNT_PATH_MAX) {
455
+ return `${at} volumes '${name}' mountPath holds at most ${VOLUME_MOUNT_PATH_MAX} characters`;
456
+ }
457
+ if (mountPath === LOG_DIR_PATH || mountPath.startsWith(`${LOG_DIR_PATH}/`)) {
458
+ return `${at} volumes '${name}' mountPath ${LOG_DIR_PATH} is the log volume's, which every app already has`;
459
+ }
460
+ if (mountPath === '/app' ||
461
+ VOLUME_FORBIDDEN_ROOTS.some((root) => mountPath === root || mountPath.startsWith(`${root}/`))) {
462
+ return `${at} volumes '${name}' mountPath must not be /app or a system path (${VOLUME_FORBIDDEN_ROOTS.join(', ')})`;
463
+ }
464
+ if (paths.has(mountPath))
465
+ return `${at} volumes mountPath '${mountPath}' is not unique`;
466
+ paths.add(mountPath);
467
+ }
468
+ return null;
469
+ }
365
470
  /**
366
471
  * `uses.auth`: its two switches are known values, sign-up is never open
367
472
  * into the staff organisation, the entry has an exact
package/dist/plan/diff.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
- import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, ownerMailboxRecorded, ownerMailboxes, ownsServices, } from '../org.js';
2
+ import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownsServices, } from '../org.js';
3
3
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
4
4
  import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_MAIL_TOKEN_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
5
5
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
@@ -19,7 +19,7 @@ import { hasAdoptedResources, networkMoveNeeded, planNetworkMove } from './netwo
19
19
  import { OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY, OWN_JOBS_BLOCKED_KEY, OWN_MAIL_BLOCKED_KEY, OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, OWN_SIGNIN_DB_MOVE_BLOCKED_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, planOwnSfu, planSigninDbMove, } from './own-diff.js';
20
20
  import { ownDnsHostnames } from './own-dns.js';
21
21
  import { ownHostnames } from './own-hostnames.js';
22
- 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, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
22
+ 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, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appSecretAvailability, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
23
23
  import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
24
24
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
25
25
  import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryRoutes, entryRoutesMarker, } from './proxy-labels.js';
@@ -2674,6 +2674,13 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
2674
2674
  * from it, which it deletes instead of parking: nothing can redeploy it.
2675
2675
  */
2676
2676
  const RETIRED_DEFAULT_APP_REPO = 'wtfalch/foundry';
2677
+ /** What the provision step says about the entry's declared volumes; empty when it declares none. */
2678
+ function volumesNote(entry) {
2679
+ const volumes = entry?.volumes ?? [];
2680
+ return volumes.length === 0
2681
+ ? ''
2682
+ : ` with volumes ${volumes.map((v) => `${v.name} at ${v.mountPath}`).join(', ')}`;
2683
+ }
2677
2684
  /**
2678
2685
  * The org's own app on Coolify (`org.ts`'s `HostingConfig.enabled`, #71), a
2679
2686
  * per-org singleton resource modeled on {@link planSite}: park, not delete,
@@ -2713,11 +2720,9 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2713
2720
  // other entry is named in them.
2714
2721
  const label = entryKey === APP_ENTRY_KEY ? 'app' : `${entryKey} app`;
2715
2722
  const blockedKey = (rest) => entryKey === APP_ENTRY_KEY ? `coolify:app:${rest}` : `coolify:app:${entryKey}:${rest}`;
2716
- const { marker: wantedEnv, unresolved, unresolvedSecrets, } = entry
2717
- ? resolveAppEnv(org, entry, recordedIds, {
2718
- sharedSecret: readiness.canHoldSecrets === true,
2719
- })
2720
- : { marker: {}, unresolved: [], unresolvedSecrets: [] };
2723
+ const { marker: wantedEnv, unresolved, unresolvedSecrets, heldSecrets, unresolvedHeld, } = entry
2724
+ ? resolveAppEnv(org, entry, recordedIds, appSecretAvailability(org, entry, readiness))
2725
+ : { marker: {}, unresolved: [], unresolvedSecrets: [], heldSecrets: [], unresolvedHeld: [] };
2721
2726
  const stepKey = appStepKey(org, entryKey);
2722
2727
  const blocked = entry
2723
2728
  ? unresolved
@@ -2732,6 +2737,24 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2732
2737
  unresolvedSecrets.map(([name, source]) => ({
2733
2738
  description: `${org.slug}'s ${label} env ${name} is blocked, not planned: ${`its source ${source} needs a held-key store that can put, and the host has none`}`,
2734
2739
  key: blockedKey(`env:${name}`),
2740
+ })))
2741
+ .concat(
2742
+ // The ref to put the value under, never a value.
2743
+ unresolvedHeld.map((name) => ({
2744
+ description: `${org.slug}'s ${label} env ${name} is blocked, not planned: ${readiness.heldEnv?.invalid?.includes(`${entryKey}/${name}`)
2745
+ ? `held secret ${name} of entry '${entryKey}' fits no held-key ref (the org id must be a lowercase UUID and the ref at most 128 characters)`
2746
+ : readiness.heldEnv?.store
2747
+ ? `held secret ${name} of entry '${entryKey}' has no value held (ref ${heldEnvRef(org.id, entryKey, name)})`
2748
+ : `held secret ${name} of entry '${entryKey}' needs a held-key store, and the host has none`}`,
2749
+ key: blockedKey(`env:${name}`),
2750
+ })))
2751
+ .concat(
2752
+ // Held, but the live env could not be read: no step, so the plan can still end empty.
2753
+ heldSecrets
2754
+ .filter((name) => readiness.heldEnv?.unreadable?.includes(heldEnvRef(org.id, entryKey, name)))
2755
+ .map((name) => ({
2756
+ description: `${org.slug}'s ${label} env ${name} is blocked, not planned: the live value of held secret ${name} of entry '${entryKey}' could not be read`,
2757
+ key: blockedKey(`env:${name}`),
2735
2758
  })))
2736
2759
  : [];
2737
2760
  // #491: the `app` entry's own Lettermint work (`executor.ts`'s
@@ -2781,7 +2804,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2781
2804
  });
2782
2805
  }
2783
2806
  const refused = noRepo || composeBuilt;
2784
- const toggled = planHostingToggle('coolify', 'app', entry !== undefined && !refused, org, byKey, claimed, () => `provision ${org.slug}'s ${label}`, () => `park ${org.slug}'s ${label} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
2807
+ const toggled = planHostingToggle('coolify', 'app', entry !== undefined && !refused, org, byKey, claimed, () => `provision ${org.slug}'s ${label}${volumesNote(entry)}`, () => `park ${org.slug}'s ${label} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
2785
2808
  // #447: only the live app's own `git_repository` decides, never the
2786
2809
  // declared entry (a `repo: null` entry can sit over a bespoke app, which is
2787
2810
  // parked as before). Only the `app` key ever held the default app, and an
@@ -2837,6 +2860,28 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2837
2860
  blocked,
2838
2861
  };
2839
2862
  }
2863
+ // A declared volume the app does not hold yet is created by the same step. Never the reverse: a
2864
+ // volume the entry no longer declares is left alone. A reader that did not list volumes
2865
+ // (`volumeMounts` absent) reads as compliant, like `hasLogVolume`.
2866
+ const mounted = live.config.volumeMounts;
2867
+ const missingVolumes = Array.isArray(mounted)
2868
+ ? (entry.volumes ?? []).filter((v) => !mounted.includes(v.mountPath))
2869
+ : [];
2870
+ if (missingVolumes.length > 0) {
2871
+ return {
2872
+ step: {
2873
+ action,
2874
+ step: {
2875
+ description: `create ${org.slug}'s ${label} volumes ${missingVolumes
2876
+ .map((v) => `${v.name} at ${v.mountPath}`)
2877
+ .join(', ')} and restart it`,
2878
+ destroys: false,
2879
+ migrates: false,
2880
+ },
2881
+ },
2882
+ blocked,
2883
+ };
2884
+ }
2840
2885
  // #543: a human turning the health check off in the Coolify UI (or an app
2841
2886
  // applied before this patch existed) is noticed the same way `hasLogVolume`
2842
2887
  // just above is -- `coolifyApp` already sends the quartet on every run, so
@@ -2911,6 +2956,23 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2911
2956
  blocked,
2912
2957
  };
2913
2958
  }
2959
+ // A held secret is planned only while its live production value differs from the held one
2960
+ // (the plan read that in memory), so a converged app plans nothing. `pushAppEnv` restarts the
2961
+ // app only when the live value differs.
2962
+ const staleHeld = heldSecrets.filter((name) => readiness.heldEnv?.differs?.includes(heldEnvRef(org.id, entryKey, name)));
2963
+ if (staleHeld.length > 0) {
2964
+ return {
2965
+ step: {
2966
+ action,
2967
+ step: {
2968
+ description: `write ${org.slug}'s ${label} held secret env ${staleHeld.join(', ')} (values read at apply)`,
2969
+ destroys: false,
2970
+ migrates: false,
2971
+ },
2972
+ },
2973
+ blocked,
2974
+ };
2975
+ }
2914
2976
  // F5 (rule 3): who signs in, which client and which issuer reach an app
2915
2977
  // that is already live. `pushAppEnv` records what it pushed.
2916
2978
  if (entry.uses?.auth &&
@@ -3018,13 +3080,14 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
3018
3080
  // The same availability the plan's marker used: the executor records its
3019
3081
  // marker from this, never from its own port, so the two cannot disagree.
3020
3082
  const usesSecrets = org.hosting.entries.some((e) => e.key === key &&
3021
- Object.values(e.env).some((v) => typeof v !== 'string' && v.from === 'sharedSecret'));
3083
+ Object.values(e.env).some((v) => typeof v !== 'string' && (v.from === 'sharedSecret' || v.from === 'heldSecret')));
3022
3084
  if (usesSecrets && app.step && app.step.action.desired.enabled !== false) {
3085
+ const entry = org.hosting.entries.find((e) => e.key === key);
3023
3086
  app.step.action.desired = {
3024
3087
  ...app.step.action.desired,
3025
- secretSources: {
3026
- sharedSecret: readiness.canHoldSecrets === true,
3027
- },
3088
+ secretSources: entry
3089
+ ? appSecretAvailability(org, entry, readiness)
3090
+ : { sharedSecret: readiness.canHoldSecrets === true },
3028
3091
  };
3029
3092
  }
3030
3093
  if (app.step && app.step.action.desired.enabled !== false) {
@@ -2138,6 +2138,14 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2138
2138
  * one).
2139
2139
  */
2140
2140
  private ensureLogVolume;
2141
+ /**
2142
+ * The entry's declared `volumes`: each is created once, found by mount path
2143
+ * like {@link ensureLogVolume}'s, and never removed or changed here. One
2144
+ * restart after the last creation, only for an app that was already running.
2145
+ * A failure part way leaves the volumes already created in place; the next
2146
+ * apply finds them by path and creates the rest.
2147
+ */
2148
+ private ensureEntryVolumes;
2141
2149
  /**
2142
2150
  * F6: a nightly dump of one of the company's databases to the platform's
2143
2151
  * backup bucket. The schedule is found before it is created
@@ -2212,6 +2220,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2212
2220
  * generates from `domains`. The router names use the app's own name, which is unique per company.
2213
2221
  */
2214
2222
  private routedLabels;
2223
+ /**
2224
+ * The value the operator holds for an entry's `heldSecret` env, registered for redaction before
2225
+ * anything else can see it. The plan found it held; a value gone since stops the step, and the
2226
+ * error names the ref, never a value.
2227
+ */
2228
+ private readHeldEnv;
2215
2229
  /**
2216
2230
  * The org's shared secret `name`: read from the held keys, else generated
2217
2231
  * and held first, so a crash after the `put` and before the env write
@@ -4,7 +4,7 @@ import { URL } from 'node:url';
4
4
  import { coolifyImageFor } from '../bootstrap/versions.js';
5
5
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
6
  import { resolveCallsSfuUrls } from '../calls-sfu.js';
7
- import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
7
+ import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
8
8
  import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
9
9
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
10
10
  import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
@@ -2515,6 +2515,7 @@ export class FoundryStepExecutor {
2515
2515
  // call -- see its own doc comment for why that decides whether to
2516
2516
  // restart it.
2517
2517
  await this.ensureLogVolume(appId, org, Boolean(existing), appNameFor(org, entry));
2518
+ await this.ensureEntryVolumes(appId, entry, Boolean(existing), appNameFor(org, entry));
2518
2519
  // The app receives whatever it needs to sign in (gap P2). Unconditional,
2519
2520
  // unlike the database push above: every app has a hostname, and an
2520
2521
  // upsert of the same value on a later apply is a no-op, not a change.
@@ -2624,6 +2625,31 @@ export class FoundryStepExecutor {
2624
2625
  await this.ports.coolify.restart(appId);
2625
2626
  }
2626
2627
  }
2628
+ /**
2629
+ * The entry's declared `volumes`: each is created once, found by mount path
2630
+ * like {@link ensureLogVolume}'s, and never removed or changed here. One
2631
+ * restart after the last creation, only for an app that was already running.
2632
+ * A failure part way leaves the volumes already created in place; the next
2633
+ * apply finds them by path and creates the rest.
2634
+ */
2635
+ async ensureEntryVolumes(appId, entry, wasAlreadyRunning, appName) {
2636
+ if (!entry.volumes?.length)
2637
+ return;
2638
+ const storages = await this.ports.coolify.listStorages(appId);
2639
+ const mounted = new Set((storages.persistent_storages ?? []).map((s) => s.mount_path));
2640
+ let created = false;
2641
+ for (const volume of entry.volumes) {
2642
+ if (mounted.has(volume.mountPath))
2643
+ continue;
2644
+ await this.ports.coolify.createPersistentStorage(appId, {
2645
+ name: `${appName}-${volume.name}`,
2646
+ mount_path: volume.mountPath,
2647
+ });
2648
+ created = true;
2649
+ }
2650
+ if (created && wasAlreadyRunning)
2651
+ await this.ports.coolify.restart(appId);
2652
+ }
2627
2653
  /**
2628
2654
  * F6: a nightly dump of one of the company's databases to the platform's
2629
2655
  * backup bucket. The schedule is found before it is created
@@ -2866,6 +2892,17 @@ export class FoundryStepExecutor {
2866
2892
  value: await this.ensureSharedSecret(org, source.name, recorded),
2867
2893
  });
2868
2894
  }
2895
+ const heldEnvs = [];
2896
+ for (const name of resolved.heldSecrets) {
2897
+ heldEnvs.push({ key: name, value: await this.readHeldEnv(org, entry.key, name) });
2898
+ }
2899
+ envs.push(...heldEnvs);
2900
+ // A held value has no version the plan can read, so the live env decides whether it changed.
2901
+ let heldChanged = false;
2902
+ if (heldEnvs.length > 0) {
2903
+ const live = await this.ports.coolify.listEnvs(appId);
2904
+ heldChanged = heldEnvs.some(({ key, value }) => !live.some((e) => e.key === key && !e.is_preview && e.value === value));
2905
+ }
2869
2906
  await this.ports.coolify.bulkUpsertEnvs(appId, envs);
2870
2907
  // `coolifyApp` records `applied` once the restart succeeded, so `planApp`
2871
2908
  // replans this step when the declared map changes (rule 3). Ids and a
@@ -2876,6 +2913,7 @@ export class FoundryStepExecutor {
2876
2913
  const authApplied = JSON.stringify(authEnv);
2877
2914
  return {
2878
2915
  changed: applied !== (recorded[appEnvAppliedScopeKey(entry.key)] ?? '{}') ||
2916
+ heldChanged ||
2879
2917
  (Boolean(entry.uses?.auth) &&
2880
2918
  authApplied !== (recorded[entryAuthEnvAppliedScopeKey(entry.key)] ?? '{}')),
2881
2919
  applied,
@@ -2916,6 +2954,20 @@ export class FoundryStepExecutor {
2916
2954
  }
2917
2955
  return { custom_labels: Buffer.from(labels).toString('base64') };
2918
2956
  }
2957
+ /**
2958
+ * The value the operator holds for an entry's `heldSecret` env, registered for redaction before
2959
+ * anything else can see it. The plan found it held; a value gone since stops the step, and the
2960
+ * error names the ref, never a value.
2961
+ */
2962
+ async readHeldEnv(org, entryKey, name) {
2963
+ const ref = heldEnvRef(org.id, entryKey, name);
2964
+ const value = await this.ports.heldKeys?.resolve(ref);
2965
+ if (!value) {
2966
+ throw new Error(`coolify:app for ${org.slug}: held secret ${name} of entry '${entryKey}' is no longer held (ref ${ref})`);
2967
+ }
2968
+ this.handledSecrets.add(value);
2969
+ return value;
2970
+ }
2919
2971
  /**
2920
2972
  * The org's shared secret `name`: read from the held keys, else generated
2921
2973
  * and held first, so a crash after the `put` and before the env write
@@ -5923,6 +5975,7 @@ export class FoundryStepExecutor {
5923
5975
  apex: ownHostnames(org).zone,
5924
5976
  version: action.desired.version,
5925
5977
  project: { ensure: () => this.ensureCoolifyProject(org, context) },
5978
+ ...(context.serverIpv4 ? { serverIpv4: context.serverIpv4 } : {}),
5926
5979
  });
5927
5980
  }
5928
5981
  async jobsRegistration(action, org, context) {
@@ -62,6 +62,13 @@ export interface ZitadelReader {
62
62
  }
63
63
  export interface CoolifyReader {
64
64
  listResources(orgId: OrgId): Promise<LiveResource[]>;
65
+ /**
66
+ * Whether the production env `name` of the org's entry app, as it is live now, equals `value`.
67
+ * Compared in memory and never returned or kept. False when the app is not recorded or has no
68
+ * such env; throws when the read fails. Optional: without it a held secret's live value cannot
69
+ * be read, and the plan blocks that env.
70
+ */
71
+ liveEnvEquals?(orgId: OrgId, appKey: string, name: string, value: string): Promise<boolean>;
65
72
  /**
66
73
  * Reads one Coolify project by its uuid, for an org that records none yet
67
74
  * (adopt, archon#271). Optional: without it nothing can be adopted.
@@ -45,6 +45,8 @@ export interface SfuAppInput {
45
45
  /** The pinned LiveKit release tag. */
46
46
  version: string;
47
47
  project: ProjectSource;
48
+ /** The platform's declared public IPv4: LiveKit's `node_ip`. Absent: the Coolify server's ip, when it is an IPv4. */
49
+ serverIpv4?: string;
48
50
  }
49
51
  /** The compose `ports` lines for a slot: the UDP range and the TCP fallback port, one to one. */
50
52
  export declare function sfuComposePorts(slot: number): string[];
@@ -1,3 +1,4 @@
1
+ import { isIPv4 } from 'node:net';
1
2
  import { DIGESTS, VERSIONS } from '../bootstrap/versions.js';
2
3
  import { mintLivekitCredentials } from '../calls-sfu-keys.js';
3
4
  import { LIVEKIT_SIGNALLING_PORT, sfuSlotPorts } from '../calls-sfu.js';
@@ -132,8 +133,12 @@ export async function provisionSfuApp(scope, deps, target, input) {
132
133
  const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
133
134
  if (!server)
134
135
  throw new Error(`${label}: no usable Coolify server is registered`);
135
- if (!server.ip)
136
- throw new Error(`${label}: the Coolify server has no address to give LiveKit`);
136
+ // Coolify reports its localhost server as `host.docker.internal`, which LiveKit refuses as `node_ip`
137
+ // and then restarts forever; the declared address wins, the same rule as the platform's mail server.
138
+ const nodeIp = input.serverIpv4 ?? (server.ip && isIPv4(server.ip) ? server.ip : undefined);
139
+ if (!nodeIp) {
140
+ throw new Error(`${label}: no public IPv4 to give LiveKit; declare the server's public IPv4 as serverIpv4 in the platform config`);
141
+ }
137
142
  const compose = sfuCompose({
138
143
  serviceName: target.internal.sfu,
139
144
  slot: input.slot,
@@ -213,7 +218,7 @@ export async function provisionSfuApp(scope, deps, target, input) {
213
218
  keysJustSet = true;
214
219
  }
215
220
  }
216
- const config = sfuConfig({ slot: input.slot, nodeIp: server.ip, keys });
221
+ const config = sfuConfig({ slot: input.slot, nodeIp, keys });
217
222
  deps.onSecret?.(config);
218
223
  const envChanged = liveConfig !== config;
219
224
  const pending = Boolean(scope.recordedIds()[OWN_SFU_DEPLOY_PENDING_SCOPE_KEY]) || changed || envChanged;
@@ -130,6 +130,12 @@ export interface StepExecutionContext {
130
130
  * platform's Docker network, not on `co-<slug>`, so Archon reaches its database. Absent: false.
131
131
  */
132
132
  readonly ownerCompany?: boolean;
133
+ /**
134
+ * The platform's declared public IPv4 (`DeclaredPlatform.serverIpv4`). A company's media server
135
+ * gives it to LiveKit as `node_ip`: Coolify reports its localhost server as `host.docker.internal`,
136
+ * which LiveKit refuses. Absent: none declared.
137
+ */
138
+ readonly serverIpv4?: string;
133
139
  /**
134
140
  * Every id foundry has recorded for this org so far — both `system:kind:key`
135
141
  * resource ids (`runSteps` records these itself from `execute`'s own return
@@ -753,6 +759,13 @@ export declare class FoundryPlanApply implements PlanApply {
753
759
  * org step that needs one is blocked.
754
760
  */
755
761
  private orgPlatformReadiness;
762
+ /**
763
+ * The org's `heldSecret` envs: the refs that hold a value now, which of those differ from the live
764
+ * production env, which could not be read, and which fit no ref. A resolver that throws counts as
765
+ * not held. The held value is compared in memory by the reader and dropped; a failed read keeps
766
+ * only the ref.
767
+ */
768
+ private heldEnvReadiness;
756
769
  /** An own org's Lettermint key is held. A resolver that throws counts as not held. */
757
770
  private ownMailKeyHeld;
758
771
  /** Which of an own org's undelivered-secret refs hold a value now, so a rerun resumes instead of blocking. */
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
3
3
  import { sfuSlotPorts } from '../calls-sfu.js';
4
- import { ownsServices, validateHosting } from '../org.js';
4
+ import { heldEnvRef, ownsServices, validateHosting } from '../org.js';
5
5
  import { PLATFORM_SERVICE_KEYS } from '../platform.js';
6
6
  import { AlreadyRunningError, StepFailedError } from '../runner/errors.js';
7
7
  import { StaleLeaseError, } from '../store.js';
@@ -908,10 +908,13 @@ export class FoundryPlanApply {
908
908
  const checkpoints = [];
909
909
  // Backed by the very `serviceIds` map this method persists once, at the
910
910
  // end — see `StepExecutionContext`'s doc comment for why.
911
- const ownerCompany = (await this.platformDeps?.store.get())?.ownerOrgId === orgId;
911
+ const declaredPlatform = await this.platformDeps?.store.get();
912
+ const ownerCompany = declaredPlatform?.ownerOrgId === orgId;
913
+ const serverIpv4 = declaredPlatform?.serverIpv4 ?? undefined;
912
914
  const context = {
913
915
  org,
914
916
  ownerCompany,
917
+ ...(serverIpv4 ? { serverIpv4 } : {}),
915
918
  recordedIds: () => ({ ...serviceIds }),
916
919
  now: () => this.now().getTime(),
917
920
  recordScopeId: (key, id) => {
@@ -1163,7 +1166,10 @@ export class FoundryPlanApply {
1163
1166
  * org step that needs one is blocked.
1164
1167
  */
1165
1168
  async orgPlatformReadiness(org, orgRecordedIds) {
1166
- const base = await this.sharedPlatformReadiness();
1169
+ const base = {
1170
+ ...(await this.sharedPlatformReadiness()),
1171
+ heldEnv: await this.heldEnvReadiness(org),
1172
+ };
1167
1173
  if (!ownsServices(org))
1168
1174
  return base;
1169
1175
  const held = this.platformDeps?.heldKeys;
@@ -1173,6 +1179,57 @@ export class FoundryPlanApply {
1173
1179
  ? { ...readiness, serviceGitRefs: serviceGitRefsOf(platform, org.versionPin) }
1174
1180
  : readiness;
1175
1181
  }
1182
+ /**
1183
+ * The org's `heldSecret` envs: the refs that hold a value now, which of those differ from the live
1184
+ * production env, which could not be read, and which fit no ref. A resolver that throws counts as
1185
+ * not held. The held value is compared in memory by the reader and dropped; a failed read keeps
1186
+ * only the ref.
1187
+ */
1188
+ async heldEnvReadiness(org) {
1189
+ const heldKeys = this.platformDeps?.heldKeys;
1190
+ const refs = [];
1191
+ const differs = [];
1192
+ const unreadable = [];
1193
+ const invalid = [];
1194
+ for (const entry of org.hosting.entries) {
1195
+ for (const [name, value] of Object.entries(entry.env)) {
1196
+ if (typeof value === 'string' || value.from !== 'heldSecret')
1197
+ continue;
1198
+ let ref;
1199
+ try {
1200
+ ref = heldEnvRef(org.id, entry.key, name);
1201
+ }
1202
+ catch {
1203
+ // Fits no ref: the plan blocks it by entry and name; the error text is fixed anyway.
1204
+ invalid.push(`${entry.key}/${name}`);
1205
+ continue;
1206
+ }
1207
+ let held;
1208
+ try {
1209
+ held = await heldKeys?.resolve(ref);
1210
+ }
1211
+ catch {
1212
+ // Not held: the plan reports the ref, and the error is dropped.
1213
+ continue;
1214
+ }
1215
+ if (!held)
1216
+ continue;
1217
+ refs.push(ref);
1218
+ try {
1219
+ const equal = await this.readers.coolify.liveEnvEquals?.(org.id, appStepKey(org, entry.key), name, held);
1220
+ if (equal === undefined)
1221
+ unreadable.push(ref);
1222
+ else if (!equal)
1223
+ differs.push(ref);
1224
+ }
1225
+ catch {
1226
+ // Blocked, not planned: the plan names the env and the entry; the error is dropped.
1227
+ unreadable.push(ref);
1228
+ }
1229
+ }
1230
+ }
1231
+ return { store: Boolean(heldKeys), refs, differs, unreadable, invalid };
1232
+ }
1176
1233
  /** An own org's Lettermint key is held. A resolver that throws counts as not held. */
1177
1234
  async ownMailKeyHeld(org) {
1178
1235
  const held = this.platformDeps?.heldKeys;
@@ -655,7 +655,17 @@ export declare function appliedAppDomains(org: DeclaredOrg, recorded: Readonly<R
655
655
  /** Whether the secret sources can be filled right now: a new shared secret can be held. */
656
656
  export interface AppSecretAvailability {
657
657
  sharedSecret: boolean;
658
+ /** The env names of the entry's `heldSecret` values that are held now. Absent: none. */
659
+ heldSecret?: readonly string[];
658
660
  }
661
+ /** What the plan decided can be filled for `entry`: the held secrets whose ref holds a value now. */
662
+ export declare function appSecretAvailability(org: DeclaredOrg, entry: HostingEntry, readiness: {
663
+ canHoldSecrets?: boolean;
664
+ heldEnv?: {
665
+ refs: readonly string[];
666
+ invalid?: readonly string[];
667
+ };
668
+ }): AppSecretAvailability;
659
669
  /**
660
670
  * The entry's `env` values that have a value, in env-name order, and the env
661
671
  * names whose source has none yet. Shared by `planApp` and the executor's
@@ -675,6 +685,9 @@ export declare function resolveAppEnv(org: DeclaredOrg, entry: HostingEntry, rec
675
685
  source: EnvSharedSecret;
676
686
  }>;
677
687
  unresolvedSecrets: Array<[string, string]>;
688
+ /** The env names of `heldSecret` values that are held, and those that are not. */
689
+ heldSecrets: string[];
690
+ unresolvedHeld: string[];
678
691
  marker: Record<string, string>;
679
692
  };
680
693
  /**
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { VERSIONS } from '../bootstrap/versions.js';
3
3
  import { APP_ENTRY_KEY } from '../org.js';
4
+ import { heldEnvRef } from '../org.js';
4
5
  import { VERSION_CHANGE_KIND, } from '../plan.js';
5
6
  import { buildDriftNotes } from './build-record.js';
6
7
  import { versionChangeNeedsConfirmation } from './confirm-upgrade.js';
@@ -737,6 +738,19 @@ export function appliedAppDomains(org, recorded, entryKey) {
737
738
  return JSON.parse(marker);
738
739
  return entryKey === APP_ENTRY_KEY ? [legacyAppHostname(org)] : [];
739
740
  }
741
+ /** What the plan decided can be filled for `entry`: the held secrets whose ref holds a value now. */
742
+ export function appSecretAvailability(org, entry, readiness) {
743
+ const refs = new Set(readiness.heldEnv?.refs ?? []);
744
+ return {
745
+ sharedSecret: readiness.canHoldSecrets === true,
746
+ heldSecret: Object.entries(entry.env)
747
+ .filter(([name, v]) => typeof v !== 'string' &&
748
+ v.from === 'heldSecret' &&
749
+ !readiness.heldEnv?.invalid?.includes(`${entry.key}/${name}`) &&
750
+ refs.has(heldEnvRef(org.id, entry.key, name)))
751
+ .map(([name]) => name),
752
+ };
753
+ }
740
754
  /**
741
755
  * The entry's `env` values that have a value, in env-name order, and the env
742
756
  * names whose source has none yet. Shared by `planApp` and the executor's
@@ -758,8 +772,19 @@ export function resolveAppEnv(org, entry, recorded, available = { sharedSecret:
758
772
  const unresolved = [];
759
773
  const secrets = [];
760
774
  const unresolvedSecrets = [];
775
+ const heldSecrets = [];
776
+ const unresolvedHeld = [];
761
777
  const marker = {};
762
778
  for (const [name, declared] of Object.entries(entry.env).sort(([a], [b]) => (a < b ? -1 : 1))) {
779
+ if (typeof declared !== 'string' && declared.from === 'heldSecret') {
780
+ if (available.heldSecret?.includes(name)) {
781
+ heldSecrets.push(name);
782
+ marker[name] = `secret:heldSecret:${name}`;
783
+ }
784
+ else
785
+ unresolvedHeld.push(name);
786
+ continue;
787
+ }
763
788
  if (typeof declared !== 'string' && declared.from === 'sharedSecret') {
764
789
  const label = `sharedSecret ${declared.name}`;
765
790
  if (available.sharedSecret) {
@@ -778,7 +803,7 @@ export function resolveAppEnv(org, entry, recorded, available = { sharedSecret:
778
803
  else if (typeof declared !== 'string')
779
804
  unresolved.push([name, declared.from]);
780
805
  }
781
- return { values, unresolved, secrets, unresolvedSecrets, marker };
806
+ return { values, unresolved, secrets, unresolvedSecrets, heldSecrets, unresolvedHeld, marker };
782
807
  }
783
808
  /**
784
809
  * #172: the resource key `planAi` plans under and `FoundryPlatformStepExecutor`
@@ -169,6 +169,20 @@ export interface OrgPlatformReadiness {
169
169
  mailKeyHeld?: boolean;
170
170
  /** The host's held keys can `put`, so an entry env from `sharedSecret` can be filled. Absent reads as false. */
171
171
  canHoldSecrets?: boolean;
172
+ /**
173
+ * The org's `heldSecret` env values. `store` is false when the host wired no held-keys reader;
174
+ * `refs` are the refs that hold a value now; `differs` are those whose live production env is not
175
+ * the held value (absent counts as different) and `unreadable` those whose live env could not be
176
+ * read; `invalid` are `<entryKey>/<NAME>` of envs that fit no ref. Refs and names only, never a
177
+ * value. Absent reads as no store.
178
+ */
179
+ heldEnv?: {
180
+ store: boolean;
181
+ refs: readonly string[];
182
+ differs?: readonly string[];
183
+ unreadable?: readonly string[];
184
+ invalid?: readonly string[];
185
+ };
172
186
  /**
173
187
  * True when the host wired no held-keys reader, so no key could be checked.
174
188
  * A missing key is then reported as "no reader given" instead of "not held".
@@ -105,6 +105,7 @@ export declare class ProvidersCoolifyReader implements CoolifyReader {
105
105
  private readonly resolveRecordedIds;
106
106
  constructor(client: CoolifyReaderClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
107
107
  listResources(orgId: OrgId): Promise<LiveResource[]>;
108
+ liveEnvEquals(orgId: OrgId, appKey: string, name: string, value: string): Promise<boolean>;
108
109
  /** The project's resources as Coolify reports them, unmapped: what adopt checks against a declaration. */
109
110
  listProject(projectUuid: string): Promise<LiveResource[]>;
110
111
  }
@@ -140,8 +140,9 @@ async function withLogVolumeConfig(client, resource, recorded) {
140
140
  }
141
141
  try {
142
142
  const storages = await client.listStorages(resource.key);
143
- const hasLogVolume = (storages.persistent_storages ?? []).some((s) => s.mount_path === LOG_DIR);
144
- return { ...resource, config: { ...resource.config, hasLogVolume } };
143
+ const volumeMounts = (storages.persistent_storages ?? []).map((s) => s.mount_path);
144
+ const hasLogVolume = volumeMounts.includes(LOG_DIR);
145
+ return { ...resource, config: { ...resource.config, hasLogVolume, volumeMounts } };
145
146
  }
146
147
  catch {
147
148
  return resource;
@@ -320,6 +321,13 @@ export class ProvidersCoolifyReader {
320
321
  const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded, liveKeys)));
321
322
  return applyRecordedIds(withBouleConfig, recorded);
322
323
  }
324
+ async liveEnvEquals(orgId, appKey, name, value) {
325
+ const recorded = await this.resolveRecordedIds(orgId);
326
+ const uuid = Object.entries(recorded).find(([, key]) => key === `app:${appKey}`)?.[0];
327
+ if (!uuid)
328
+ return false;
329
+ return (await this.client.listEnvs(uuid)).some((e) => e.key === name && !e.is_preview && e.value === value);
330
+ }
323
331
  /** The project's resources as Coolify reports them, unmapped: what adopt checks against a declaration. */
324
332
  async listProject(projectUuid) {
325
333
  return toFoundryResources('coolify', await this.client.listResources(projectUuid));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.39.6",
3
+ "version": "0.41.1",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",