@wtfalch/foundry 0.22.2 → 0.22.4

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
@@ -20,7 +20,7 @@ export type { FoundryStoreDb } from './store/postgres.js';
20
20
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type AppliedVersionRow, type NewAppliedVersionRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
21
21
  export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, StepSystem, PlanDetail, PlatformPlanDetail, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
22
22
  export { AdoptRefusedError } from './plan/index.js';
23
- export { createEdgeAccess, createEdgeDns, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
23
+ export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
24
24
  export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, DnsPort, EdgePlatformAccess, EdgePlatformDns, EdgePlatformRateLimits, EdgePlatformWorkers, EdgePlatformZones, EdgeZonesOptions, ZonePort, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, EdgeWorkersOptions, EdgePlatformTunnels, TunnelPort, } from './plan/index.js';
25
25
  export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
26
26
  export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
@@ -32,7 +32,7 @@ export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from
32
32
  export { HttpJobsSchedulerClient } from './plan/index.js';
33
33
  export type { HealthTarget, HealthTargetDepth, HealthTargetExpect } from './plan/index.js';
34
34
  export { healthTargets, platformHealthTargets } from './plan/index.js';
35
- export type { ScopeResolver, RecordedIds } from './plan/index.js';
35
+ export type { ScopeResolver, RecordedIds, DnsRecordOwners } from './plan/index.js';
36
36
  export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
37
37
  export type { ZitadelListAppsRawApp } from './plan/index.js';
38
38
  export { adaptZitadelListApps } from './plan/index.js';
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, cre
12
12
  export { createPostgresAuditLog } from './store/audit.js';
13
13
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
14
14
  export { AdoptRefusedError } from './plan/index.js';
15
- export { createEdgeAccess, createEdgeDns, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
15
+ export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
16
16
  export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
17
17
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
18
18
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
package/dist/plan/diff.js CHANGED
@@ -3,13 +3,14 @@ import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
3
3
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
4
4
  import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
5
5
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
6
+ import { chatInternalNames } from './chat-matrix.js';
6
7
  import { planEntryAiKeys } from './entry-ai-key.js';
7
8
  import { planEdgeReservedLabels, planEntryEdgeKeys } from './entry-edge-key.js';
8
9
  import { planFilesKeys } from './entry-files-key.js';
9
10
  import { appHostname, entryHostnames, tunnelDnsHostnames, tunnelDomainList } from './hostnames.js';
10
11
  import { LOGIN_BINDING_SCOPE_KEY, companyLoginTunnelHost, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
11
12
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
12
- 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, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, } from './platform-diff.js';
13
+ 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, appEnvAppliedScopeKey, appFolderDiffers, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, } from './platform-diff.js';
13
14
  import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
14
15
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
15
16
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec } from './proxy-labels.js';
@@ -27,6 +28,74 @@ export function toPlan(detail) {
27
28
  function resourceKey(system, kind, key) {
28
29
  return `${system}:${kind}:${key}`;
29
30
  }
31
+ /**
32
+ * Whether an unclaimed live resource is one foundry made for the org, so the
33
+ * plan does not list it as a stranger. Nothing here claims a resource: no step
34
+ * is planned from it and none is removed.
35
+ *
36
+ * - An id foundry recorded, under any key. One step often makes several
37
+ * resources and records the others under a flat key (`coolify_chat_synapse`,
38
+ * `zitadel_project`), which the readers do not map to a name. Only a
39
+ * resource still under its provider's own kind and id is matched: a mapped
40
+ * one carries foundry's key (the org id, a name), which other recorded
41
+ * values equal, and a planner that leaves it unclaimed wants it listed.
42
+ * - The environment of the org's own Coolify project.
43
+ * - The chat login app. Its id is recorded under `coolify:chat:<org>` until
44
+ * the introspection step records the chat service there instead.
45
+ * - A DNS record edge holds a row for (`config.owner`): foundry or the company
46
+ * wrote it through edge.
47
+ * - A DNS record written before edge held rows: a tunnel hostname foundry
48
+ * published that points at a tunnel, or the mail records of the recorded
49
+ * mail domain.
50
+ */
51
+ function madeByFoundry(org, r, recordedIds, recordedValues) {
52
+ if (PROVIDER_KINDS[r.system]?.includes(r.kind) && recordedValues.has(r.key))
53
+ return true;
54
+ if (r.system === 'coolify') {
55
+ if (r.kind === 'environment')
56
+ return true;
57
+ return r.kind === 'application' && r.config.name === chatInternalNames(org.slug).mas;
58
+ }
59
+ if (r.system !== 'cloudflare' || r.kind !== 'dns_record')
60
+ return false;
61
+ if (typeof r.config.owner === 'string')
62
+ return true;
63
+ const name = typeof r.config.name === 'string' ? r.config.name.toLowerCase() : '';
64
+ if (name === '')
65
+ return false;
66
+ const type = r.config.type;
67
+ const content = typeof r.config.content === 'string' ? r.config.content.toLowerCase() : '';
68
+ const tunnelHostnames = (recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY] ?? '')
69
+ .toLowerCase()
70
+ .split(',');
71
+ if (type === 'CNAME' && content.endsWith('.cfargotunnel.com') && tunnelHostnames.includes(name)) {
72
+ return true;
73
+ }
74
+ const mailDomain = recordedIds[MAIL_DOMAIN_SCOPE_KEY]?.toLowerCase();
75
+ if (!mailDomain)
76
+ return false;
77
+ if (name === mailDomain) {
78
+ return type === 'MX' || (type === 'TXT' && /^"?v=spf1\b/.test(content));
79
+ }
80
+ return name === `_dmarc.${mailDomain}` || name.endsWith(`._domainkey.${mailDomain}`);
81
+ }
82
+ /** The kinds a provider lists a resource under before a recorded `system:kind:key` maps it to foundry's own. */
83
+ const PROVIDER_KINDS = {
84
+ coolify: ['application', 'database'],
85
+ zitadel: ['project', 'app'],
86
+ };
87
+ /**
88
+ * What a listed resource is called: the provider's own name for it, then its
89
+ * id, which tells two resources of one name apart. The id alone when it has
90
+ * no name.
91
+ */
92
+ function liveResourceName(r) {
93
+ const name = typeof r.config.name === 'string' && r.config.name !== '' ? r.config.name : null;
94
+ if (name === null || name === r.key)
95
+ return r.key;
96
+ const type = r.kind === 'dns_record' && typeof r.config.type === 'string' ? `${r.config.type} ` : '';
97
+ return `${type}${name}, ${r.key}`;
98
+ }
30
99
  /**
31
100
  * The diff engine: #19's "a plan reads live state from Zitadel, Coolify and
32
101
  * Cloudflare and diffs it against the declared org", built on #10's rule
@@ -356,10 +425,17 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
356
425
  }
357
426
  // #10 (resolved): "foundry manages only what the declared org names";
358
427
  // anything left unclaimed is listed, never proposed for deletion.
428
+ // A resource foundry made for the org is not a finding: only a resource
429
+ // nobody recorded is listed, under the name its provider shows.
430
+ // The org's own id and names are recorded as values too, and are no provider id.
431
+ const recordedValues = new Set(Object.values(recordedIds));
432
+ for (const own of ['', org.id, org.slug, org.domain ?? ''])
433
+ recordedValues.delete(own);
359
434
  const unmanaged = live
360
435
  .filter((r) => !claimed.has(resourceKey(r.system, r.kind, r.key)))
436
+ .filter((r) => !madeByFoundry(org, r, recordedIds, recordedValues))
361
437
  .map((r) => ({
362
- description: `${r.system} ${r.kind} "${r.key}" is live but not named by the declared org`,
438
+ description: `${r.system} ${r.kind} "${liveResourceName(r)}" is live but not named by the declared org`,
363
439
  }));
364
440
  // A blocked Agora is reported here, not planned: a step the executor must
365
441
  // refuse would halt every step after it. This is the plan's one non-step
@@ -2229,7 +2305,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2229
2305
  key: blockedKey('port'),
2230
2306
  });
2231
2307
  }
2232
- if (want.folder !== have.folder ||
2308
+ if (appFolderDiffers(want, have) ||
2233
2309
  want.autoDeploy !== have.autoDeploy ||
2234
2310
  (want.port !== null && want.port !== have.port)) {
2235
2311
  return {
@@ -47,6 +47,11 @@ export interface DnsPort {
47
47
  }>;
48
48
  deleteDnsRecord(zone: string, recordId: string): Promise<void>;
49
49
  }
50
+ /**
51
+ * The owner edge reports for each record of a zone that has an edge row, by
52
+ * record id. For `ProvidersCloudflareReader`'s `resolveDnsOwners`.
53
+ */
54
+ export declare function edgeDnsRecordOwners(client: Pick<EdgePlatformDns, 'list'>, zone: string): Promise<Record<string, string>>;
50
55
  /** Edge answers its platform writes from one shared slot of 4 (company-less writes). */
51
56
  export declare const EDGE_DNS_WRITE_CONCURRENCY = 4;
52
57
  /**
@@ -1,4 +1,16 @@
1
1
  import { EdgeApiError } from '@wtfalch/edge';
2
+ /**
3
+ * The owner edge reports for each record of a zone that has an edge row, by
4
+ * record id. For `ProvidersCloudflareReader`'s `resolveDnsOwners`.
5
+ */
6
+ export async function edgeDnsRecordOwners(client, zone) {
7
+ const owners = {};
8
+ for (const record of (await client.list({ zone })).records) {
9
+ if (record.owner !== null)
10
+ owners[record.id] = record.owner;
11
+ }
12
+ return owners;
13
+ }
2
14
  /** Edge answers its platform writes from one shared slot of 4 (company-less writes). */
3
15
  export const EDGE_DNS_WRITE_CONCURRENCY = 4;
4
16
  /**
@@ -117,8 +117,6 @@ export interface CoolifyPort {
117
117
  ports_exposes?: string;
118
118
  /** `false` for an internal entry: Coolify makes no domain for it. */
119
119
  autogenerate_domain?: boolean;
120
- /** The folder a `static` entry serves (`HostingEntry.folder`). */
121
- publish_directory?: string;
122
120
  /** Whether a push to `git_branch` deploys (`HostingEntry.autoDeploy`). */
123
121
  is_auto_deploy_enabled?: boolean;
124
122
  /**
@@ -23,7 +23,7 @@ import { healthCheckSettings } from './health-check-settings.js';
23
23
  import { appHostname, coolifyTunnelDomains, entryHostnames, tunnelDnsHostnames, tunnelDomainList, tunnelIngressHostnames, } from './hostnames.js';
24
24
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
25
25
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
26
- 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, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, } from './platform-diff.js';
26
+ 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, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, } from './platform-diff.js';
27
27
  import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, } from './platform-providers.js';
28
28
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
29
29
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
@@ -1789,7 +1789,6 @@ export class FoundryStepExecutor {
1789
1789
  const names = entryHostnames(org, entry);
1790
1790
  const wantDomains = tunnelDomainList(names);
1791
1791
  const isStatic = entry.buildPack === 'static';
1792
- const folderField = isStatic ? 'publish_directory' : 'base_directory';
1793
1792
  const settingsKey = appSettingsAppliedScopeKey(entry.key);
1794
1793
  const domainsKey = appDomainsAppliedScopeKey(entry.key);
1795
1794
  let haveDomains;
@@ -1833,7 +1832,10 @@ export class FoundryStepExecutor {
1833
1832
  // A static site is served by Coolify's own web server on port 80.
1834
1833
  ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
1835
1834
  is_auto_deploy_enabled: entry.autoDeploy,
1836
- [folderField]: entry.folder === null ? undefined : `/${entry.folder}`,
1835
+ // Also the folder a static entry serves: Coolify's static build
1836
+ // copies `base_directory`, and reads `publish_directory` only for a
1837
+ // built app.
1838
+ base_directory: entry.folder === null ? undefined : `/${entry.folder}`,
1837
1839
  // Only the shared standard app builds from this repo; a bespoke
1838
1840
  // app's Dockerfile is at its own repo's root.
1839
1841
  dockerfile_location: entry.repo ? undefined : DEFAULT_APP_DOCKERFILE,
@@ -1848,8 +1850,8 @@ export class FoundryStepExecutor {
1848
1850
  // difference again.
1849
1851
  const want = appSettingsOf(entry);
1850
1852
  const patch = {};
1851
- if (want.folder !== have.folder)
1852
- patch[folderField] = want.folder ? `/${want.folder}` : '/';
1853
+ if (appFolderDiffers(want, have))
1854
+ patch.base_directory = want.folder ? `/${want.folder}` : '/';
1853
1855
  if (want.port !== have.port && want.port !== null && !isStatic) {
1854
1856
  patch.ports_exposes = String(want.port);
1855
1857
  }
@@ -1904,7 +1906,6 @@ export class FoundryStepExecutor {
1904
1906
  await this.ports.coolify.updateApplication(appId, patch);
1905
1907
  // The container builds and listens from these only at its next deploy.
1906
1908
  const settingsNeedDeploy = Boolean(patch.base_directory ||
1907
- patch.publish_directory ||
1908
1909
  patch.ports_exposes ||
1909
1910
  patch.domains !== undefined ||
1910
1911
  patch.custom_internal_name ||
@@ -26,7 +26,7 @@ export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, Call
26
26
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
27
27
  export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './mail-copy.js';
28
28
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
29
- export type { ScopeResolver, RecordedIds } from './providers-readers.js';
29
+ export type { ScopeResolver, RecordedIds, DnsRecordOwners } from './providers-readers.js';
30
30
  export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
31
31
  export type { AgoraHostPort, AgoraHostValues } from './agora.js';
32
32
  export type { BrokerPort } from './broker-entries.js';
@@ -41,7 +41,7 @@ export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedu
41
41
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
42
42
  export type { HealthTarget, HealthTargetDepth, HealthTargetExpect } from './health-targets.js';
43
43
  export { healthTargets, platformHealthTargets } from './health-targets.js';
44
- export { createEdgeDns, EDGE_DNS_WRITE_CONCURRENCY } from './edge-dns.js';
44
+ export { createEdgeDns, edgeDnsRecordOwners, EDGE_DNS_WRITE_CONCURRENCY } from './edge-dns.js';
45
45
  export type { DnsPort, DnsRecordInput, DnsRecordSummary, EdgePlatformDns } from './edge-dns.js';
46
46
  export { createEdgeAccess } from './edge-access.js';
47
47
  export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, EdgePlatformAccess, } from './edge-access.js';
@@ -21,7 +21,7 @@ export { checkMailIdentityAudience } from './mail-identity.js';
21
21
  export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
22
22
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
23
23
  export { healthTargets, platformHealthTargets } from './health-targets.js';
24
- export { createEdgeDns, EDGE_DNS_WRITE_CONCURRENCY } from './edge-dns.js';
24
+ export { createEdgeDns, edgeDnsRecordOwners, EDGE_DNS_WRITE_CONCURRENCY } from './edge-dns.js';
25
25
  export { createEdgeAccess } from './edge-access.js';
26
26
  export { createEdgeWorkers } from './edge-workers.js';
27
27
  export { createEdgeRateLimits } from './edge-ratelimits.js';
@@ -482,8 +482,17 @@ export interface AppSettings {
482
482
  folder: string | null;
483
483
  port: number | null;
484
484
  autoDeploy: boolean;
485
+ /**
486
+ * Set for a `static` entry whose folder is written as `base_directory`,
487
+ * the field Coolify's static build serves from. A static marker without it
488
+ * was written as `publish_directory`, which that build ignores (it served
489
+ * the repo root), so the app is patched and redeployed once.
490
+ */
491
+ staticFolderIn?: 'base_directory';
485
492
  }
486
493
  export declare function appSettingsOf(entry: HostingEntry): AppSettings;
494
+ /** Whether the app's folder is not yet where Coolify reads it: a changed folder, or a static one written to the old field. */
495
+ export declare function appFolderDiffers(want: AppSettings, have: AppSettings): boolean;
487
496
  /** What an app that predates the marker has: Coolify's own defaults, so it plans no change. */
488
497
  export declare const DEFAULT_APP_SETTINGS: AppSettings;
489
498
  export declare function appSettingsAppliedScopeKey(entryKey: string): string;
@@ -520,7 +520,17 @@ export function serviceDbEnvAppliedScopeKey(service) {
520
520
  return `service_db_env_applied.${service}`;
521
521
  }
522
522
  export function appSettingsOf(entry) {
523
- return { folder: entry.folder, port: entry.port, autoDeploy: entry.autoDeploy };
523
+ return {
524
+ folder: entry.folder,
525
+ port: entry.port,
526
+ autoDeploy: entry.autoDeploy,
527
+ ...(entry.buildPack === 'static' ? { staticFolderIn: 'base_directory' } : {}),
528
+ };
529
+ }
530
+ /** Whether the app's folder is not yet where Coolify reads it: a changed folder, or a static one written to the old field. */
531
+ export function appFolderDiffers(want, have) {
532
+ return (want.folder !== have.folder ||
533
+ (want.folder !== null && want.staticFolderIn !== have.staticFolderIn));
524
534
  }
525
535
  /** What an app that predates the marker has: Coolify's own defaults, so it plans no change. */
526
536
  export const DEFAULT_APP_SETTINGS = { folder: null, port: null, autoDeploy: true };
@@ -99,16 +99,29 @@ export declare class ProvidersCoolifyReader implements CoolifyReader {
99
99
  /** The project's resources as Coolify reports them, unmapped: what adopt checks against a declaration. */
100
100
  listProject(projectUuid: string): Promise<LiveResource[]>;
101
101
  }
102
- /**
103
- * Backed by `@wtfalch/providers/cloudflare`'s `CloudflareClient.listResources`:
104
- * a zone's DNS records plus its email-routing status (v1 scope — see that
105
- * method's own doc comment for what it leaves out).
106
- */
102
+ /** Record id to the owner edge reports for it (`platform` or `organisation`); a record with no edge row is absent. */
103
+ export type DnsRecordOwners = (orgId: OrgId) => Promise<Readonly<Record<string, string>>>;
107
104
  export declare class ProvidersCloudflareReader implements CloudflareReader {
108
105
  private readonly client;
109
106
  private readonly resolveScope;
110
107
  private readonly resolveRecordedIds;
111
- constructor(client: CloudflareClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
108
+ /**
109
+ * Who edge holds each DNS record of the org's zone for. A `dns_record`
110
+ * with a row gets `config.owner`, and the plan does not list it as a
111
+ * stranger. Left out, every record reads as having no row. A failing
112
+ * lookup fails the read: a plan that listed those records as strangers
113
+ * would not match the next one.
114
+ */
115
+ private readonly resolveDnsOwners?;
116
+ constructor(client: CloudflareClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds,
117
+ /**
118
+ * Who edge holds each DNS record of the org's zone for. A `dns_record`
119
+ * with a row gets `config.owner`, and the plan does not list it as a
120
+ * stranger. Left out, every record reads as having no row. A failing
121
+ * lookup fails the read: a plan that listed those records as strangers
122
+ * would not match the next one.
123
+ */
124
+ resolveDnsOwners?: DnsRecordOwners | undefined);
112
125
  listResources(orgId: OrgId): Promise<LiveResource[]>;
113
126
  }
114
127
  /**
@@ -303,23 +303,39 @@ export class ProvidersCoolifyReader {
303
303
  * a zone's DNS records plus its email-routing status (v1 scope — see that
304
304
  * method's own doc comment for what it leaves out).
305
305
  */
306
+ const NO_DNS_OWNERS = {};
306
307
  export class ProvidersCloudflareReader {
307
308
  client;
308
309
  resolveScope;
309
310
  resolveRecordedIds;
310
- constructor(client, resolveScope, resolveRecordedIds) {
311
+ resolveDnsOwners;
312
+ constructor(client, resolveScope, resolveRecordedIds,
313
+ /**
314
+ * Who edge holds each DNS record of the org's zone for. A `dns_record`
315
+ * with a row gets `config.owner`, and the plan does not list it as a
316
+ * stranger. Left out, every record reads as having no row. A failing
317
+ * lookup fails the read: a plan that listed those records as strangers
318
+ * would not match the next one.
319
+ */
320
+ resolveDnsOwners) {
311
321
  this.client = client;
312
322
  this.resolveScope = resolveScope;
313
323
  this.resolveRecordedIds = resolveRecordedIds;
324
+ this.resolveDnsOwners = resolveDnsOwners;
314
325
  }
315
326
  async listResources(orgId) {
316
327
  const zoneId = await this.resolveScope(orgId);
317
328
  if (!zoneId)
318
329
  return [];
319
- const [resources, recorded] = await Promise.all([
330
+ const [listed, recorded, owners] = await Promise.all([
320
331
  this.client.listResources(zoneId),
321
332
  this.resolveRecordedIds(orgId),
333
+ this.resolveDnsOwners?.(orgId) ?? NO_DNS_OWNERS,
322
334
  ]);
335
+ const resources = listed.map((resource) => {
336
+ const owner = resource.kind === 'dns_record' ? owners[resource.key] : undefined;
337
+ return owner ? { ...resource, config: { ...resource.config, owner } } : resource;
338
+ });
323
339
  // A zone needs no mapping: its key is already the zone name, which is
324
340
  // foundry's own zone key (`diff.ts`'s `planZone`), so it never matches a
325
341
  // recorded provider id and always passes through unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.22.2",
3
+ "version": "0.22.4",
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",