@wtfalch/foundry 0.22.3 → 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,6 +3,7 @@ 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';
@@ -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
@@ -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
  /**
@@ -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';
@@ -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.3",
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",