@wtfalch/foundry 0.17.0 → 0.17.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.
@@ -2,7 +2,7 @@ import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/p
2
2
  import type { DeclaredOrg, HostingBuildPack } from '../org.js';
3
3
  import type { DeclaredOrgStore } from '../store.js';
4
4
  import { type BrokerPort } from './broker-entries.js';
5
- import type { StepAction } from './diff.js';
5
+ import { type StepAction } from './diff.js';
6
6
  import { type LoginHostBindingsPort } from './login-host.js';
7
7
  import { type MailCopyPort } from './mail-copy.js';
8
8
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
@@ -80,11 +80,25 @@ export interface CoolifyPort {
80
80
  * `findOrCreateCmsApp`'s own check of whether an app it found by name
81
81
  * (rather than just created) needs starting again (#116 review round 3,
82
82
  * Finding 1).
83
+ *
84
+ * #480: also the rolling-update/health-check settings `updateApplication`
85
+ * patches on `platform:archon` (`is_consistent_container_name_enabled`,
86
+ * `custom_network_aliases`, `health_check_enabled`), so a drift check can
87
+ * tell a setting changed back by hand from one this run already applied.
88
+ * `is_consistent_container_name_enabled` comes back nested under
89
+ * `settings`, not at the top level -- confirmed against live Coolify
90
+ * 4.3.23 (PR #482 review); `custom_network_aliases` and
91
+ * `health_check_enabled` are both top-level there.
83
92
  */
84
93
  getApplication(uuid: string): Promise<{
85
94
  uuid: string;
86
95
  status?: string;
87
96
  custom_labels?: string | null;
97
+ settings?: {
98
+ is_consistent_container_name_enabled?: boolean;
99
+ };
100
+ custom_network_aliases?: string | null;
101
+ health_check_enabled?: boolean;
88
102
  }>;
89
103
  createGithubAppApplication(input: {
90
104
  project_uuid: string;
@@ -535,6 +549,72 @@ export interface CloudflarePort {
535
549
  */
536
550
  request<T>(method: string, path: string, body?: unknown, missingOkay?: boolean): Promise<T>;
537
551
  }
552
+ /**
553
+ * One Cloudflare Workers rate limiting binding, Cloudflare's own "simple"
554
+ * shape -- the metadata part {@link WorkersPort.uploadScript} sends beside
555
+ * the module (#318).
556
+ */
557
+ export interface WorkersRateLimitBinding {
558
+ type: 'ratelimit';
559
+ name: string;
560
+ namespace_id: string;
561
+ simple: {
562
+ limit: number;
563
+ period: number;
564
+ };
565
+ }
566
+ /**
567
+ * The Cloudflare Workers calls `cloudflareWorker` makes (#318, F8): deploys
568
+ * a plain Worker from an entry's `edge` declaration and routes it on the
569
+ * org's own zone. Optional on {@link FoundryStepExecutorPorts} -- a host
570
+ * with no Workers rights on its Cloudflare token (the issue's own "Human
571
+ * steps": the token needs them added first) wires none, and the planner
572
+ * reports every `edge` entry blocked instead of planning a step that would
573
+ * throw (`FoundryPlanApplyOptions.edgeWorkers`).
574
+ *
575
+ * `uploadScript` takes the *declared artifact reference*, not bytes:
576
+ * fetching `artifactUrl` and verifying it against `artifactSha256` happens
577
+ * behind this port, never in `cloudflareWorker` or anywhere else in this
578
+ * package (estate DECISIONS.md, 2026-10-04, "offline.tf Worker setup" --
579
+ * "Foundry consumes and deploys the artifact through the provider
580
+ * boundary... No manual build step and no source checkout/bundling in
581
+ * Foundry"). The real adapter refuses a fetched body whose digest does not
582
+ * match. The upload itself is a multipart request Cloudflare's API wants (a
583
+ * `metadata` part naming `main_module` and `bindings`, plus the module) --
584
+ * `CloudflarePort.request` sends JSON only, so this port names the call
585
+ * directly rather than forcing it through that escape hatch.
586
+ *
587
+ * `deleteRoute`/`deleteScript` are idempotent: deleting a route or script
588
+ * that is already gone resolves rather than throwing, the same contract
589
+ * `CloudflarePort.deleteDnsRecord`/`deleteAccessApp` give their own
590
+ * already-gone case via `isNotFoundError` at the call site -- here it is
591
+ * the port's own job, since `cloudflareWorker` deletes a route or script by
592
+ * id/name it already recorded, with nothing live to re-check first.
593
+ */
594
+ export interface WorkersPort {
595
+ /** Whether a script named `name` already exists -- checked only on an entry's first-ever apply, so `cloudflareWorker` can refuse to overwrite a script it did not create instead of PUTting over it by its deterministic name. */
596
+ scriptExists(name: string): Promise<boolean>;
597
+ uploadScript(input: {
598
+ name: string;
599
+ artifactUrl: string;
600
+ artifactSha256: string;
601
+ compatibilityDate: string;
602
+ bindings?: WorkersRateLimitBinding[];
603
+ }): Promise<void>;
604
+ /** The script name currently bound to `pattern` in `zoneId`, or `undefined` for no route there. */
605
+ findRoute(zoneId: string, pattern: string): Promise<{
606
+ id: string;
607
+ script: string;
608
+ } | undefined>;
609
+ /** Creates the route if absent; `cloudflareWorker` only calls this once `findRoute` has confirmed the pattern is foundry's own or unclaimed. */
610
+ createRoute(zoneId: string, pattern: string, script: string): Promise<{
611
+ id: string;
612
+ }>;
613
+ /** A no-op, not a throw, when `routeId` is already gone. */
614
+ deleteRoute(zoneId: string, routeId: string): Promise<void>;
615
+ /** A no-op, not a throw, when `name` is already gone. */
616
+ deleteScript(name: string): Promise<void>;
617
+ }
538
618
  /**
539
619
  * One DNS record a Lettermint domain's own response says must be published --
540
620
  * `purpose` is Lettermint's own label (`return_path`, `dmarc`, `dkim_legacy`,
@@ -1257,6 +1337,37 @@ export interface AiOrganisationPort {
1257
1337
  limitMicros: string;
1258
1338
  }): Promise<unknown>;
1259
1339
  }
1340
+ /**
1341
+ * #317 (F7): the names service's own management API
1342
+ * (https://github.com/wtfalch/names, live on main, offering `mintKey`,
1343
+ * `revokeKey` and `setReservedLabels`) -- this port states the shape this
1344
+ * executor assumes of it. `organisationId` is the org itself: unlike
1345
+ * `AiOrganisationPort.mintNestedKey`, names takes no separate minter key
1346
+ * id, so `namesEntryKey` mints directly below the org. A real adapter
1347
+ * wiring this port into `platform-providers.ts` is tracked separately
1348
+ * (https://github.com/wtfalch/archon/issues/339).
1349
+ */
1350
+ export interface NamesPort {
1351
+ /**
1352
+ * `domain` is required: names's own `mintKey` refuses a request without a
1353
+ * lowercase hostname and binds the key to it
1354
+ * (`wtfalch/names`'s `packages/service/src/keys.ts`) -- `namesEntryKey`
1355
+ * passes `org.domain`, which `validateEntryNames` already requires before
1356
+ * `uses.names` is accepted.
1357
+ */
1358
+ mintKey(input: {
1359
+ organisationId: string;
1360
+ domain: string;
1361
+ name: string;
1362
+ expiresDays: number;
1363
+ }): Promise<{
1364
+ id: string;
1365
+ secret: string;
1366
+ }>;
1367
+ revokeKey(organisationId: string, keyId: string): Promise<void>;
1368
+ /** Refuses a label any hosting entry of `organisationId` already holds. Rerun whenever the org's own label set changes. */
1369
+ setReservedLabels(organisationId: string, labels: string[]): Promise<void>;
1370
+ }
1260
1371
  /**
1261
1372
  * #173: the jobs scheduler's own admin API
1262
1373
  * (https://github.com/wtfalch/jobs, "The admin API", jobs#62), authenticated
@@ -1325,6 +1436,15 @@ export interface FoundryStepExecutorPorts {
1325
1436
  /** Flat, not a factory: `organisationId` is an explicit argument on every method of both ports (see each port's own doc comment; #177 dropped the `filesLimitsFor`/`aiLimitsFor` factories this used to need). */
1326
1437
  filesOrganisation?: FilesOrganisationPort;
1327
1438
  aiOrganisation?: AiOrganisationPort;
1439
+ /**
1440
+ * #317 (F7): mints an entry's names key -- see {@link NamesPort}'s own
1441
+ * doc comment. Optional the same way `jobsScheduler` is: a host with no
1442
+ * names service deployed builds the executor without one, and tells the
1443
+ * planner so (`FoundryPlanApplyOptions.names`), which then reports each
1444
+ * entry's `uses.names` step as blocked instead of planning one that would
1445
+ * throw.
1446
+ */
1447
+ names?: NamesPort;
1328
1448
  /**
1329
1449
  * #177: builds the files, AI and jobs ports from the platform's addresses
1330
1450
  * and held keys. A port set above overrides it (tests); with neither, the
@@ -1347,6 +1467,13 @@ export interface FoundryStepExecutorPorts {
1347
1467
  * `FoundryPlanApplyOptions.broker`).
1348
1468
  */
1349
1469
  broker?: BrokerPort;
1470
+ /**
1471
+ * #318: deploys and routes an entry's `edge` Worker -- see
1472
+ * {@link WorkersPort}'s own doc comment. Optional the same way `broker` is:
1473
+ * without it the plan reports each `edge` entry blocked (the host also
1474
+ * sets `FoundryPlanApplyOptions.edgeWorkers`).
1475
+ */
1476
+ workers?: WorkersPort;
1350
1477
  /**
1351
1478
  * Hands a just-minted, one-time secret (today: an API app's
1352
1479
  * `clientSecret`) to the host, immediately after the provider returns it
@@ -1490,6 +1617,49 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1490
1617
  * holds the hostname.
1491
1618
  */
1492
1619
  private cloudflareRateLimit;
1620
+ /**
1621
+ * `plan/diff.ts`'s `planEdgeWorkers` (#318, F8): deploys one entry's
1622
+ * `edge` Worker and routes it on the org's own zone. Throws by name
1623
+ * without a {@link WorkersPort}, the same `jobsRegistration` shape --
1624
+ * `planEdgeWorkers` reports the step blocked instead while the host has
1625
+ * none, so this only fires against a stale plan. The route is checked
1626
+ * before upload: a foreign route already on the pattern is refused, never
1627
+ * adopted or replaced (`cloudflareAccess`'s own precedent for a foreign
1628
+ * resource on the same name), so a Worker is never deployed only to find
1629
+ * nothing can route to it.
1630
+ *
1631
+ * Order on apply: record (and persist) the uploaded name and clear the
1632
+ * retired marker, upload, record the pending route, create the route only
1633
+ * if needed, delete any *previous* route this entry recorded on its own
1634
+ * zone, then record the new route id/zone/pattern and the applied
1635
+ * declaration last -- every crash point leaves a recorded trace that
1636
+ * retire and destroy can clean up (#318 re-review), and per CLAUDE.md's
1637
+ * "Declared changes apply" nothing is marked done until the
1638
+ * live state actually matches it.
1639
+ */
1640
+ private cloudflareWorker;
1641
+ /**
1642
+ * The zone id a recorded route lives in, or `undefined` only when that
1643
+ * zone is *positively* gone -- its apex resolves to no zone, or to a
1644
+ * different zone than the one recorded (#318 re-review). Reads the zone
1645
+ * recorded beside the route, never `org.domain`: the route id and its
1646
+ * zone are always recorded together (the apply branch below writes both
1647
+ * in the same batch, and the pending-route marker carries its own zone
1648
+ * too), so a route id with no zone recorded cannot happen within this
1649
+ * feature -- and re-resolving from `org.domain` as a fallback would risk
1650
+ * looking up the *new* zone after a domain change and leaving the route
1651
+ * live on the old one, the exact bug a recorded-per-route zone exists to
1652
+ * avoid (#318 re-review).
1653
+ */
1654
+ private liveEdgeZone;
1655
+ /** Deletes the route an interrupted apply was creating, if `findRoute` shows it exists, points at that apply's script, and is not `keepRouteId`. */
1656
+ private deletePendingEdgeRoute;
1657
+ /**
1658
+ * Retire and destroy share this: delete the recorded route on its own
1659
+ * zone, then any route an interrupted apply created but never recorded,
1660
+ * then the script. Records nothing -- the caller does.
1661
+ */
1662
+ private teardownEdgeWorker;
1493
1663
  private zitadelIssuer;
1494
1664
  /**
1495
1665
  * `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
@@ -1761,6 +1931,22 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1761
1931
  * comment), never logged, never interpolated into a thrown error -- the
1762
1932
  * same discipline `pushDatabaseUrl`'s doc comment states for a database's
1763
1933
  * own connection string.
1934
+ *
1935
+ * #491: none of the above applies to the org whose mail domain is the
1936
+ * platform's own apex -- it shares the platform's existing Lettermint
1937
+ * project (`platformLettermintProjectId`, from `DeclaredPlatform.mail`)
1938
+ * instead of minting a `<slug>-app` one, and this method never creates or
1939
+ * deletes a project for it. `platformApex`/`platformLettermintProjectId`
1940
+ * come straight off this step's own `desired`, the same "the executor
1941
+ * writes exactly what the plan showed" rule `coolifyMail` already follows
1942
+ * for its own `apex`/`mailHost` -- the caller (`coolifyApp`) refuses a
1943
+ * `desired` missing the `platformApex` key outright as a stale plan --
1944
+ * checked here, after the no-op-until-a-mail-domain-exists return just
1945
+ * below, rather than unconditionally at the call site: every other
1946
+ * `coolify:app` concern (hostnames, ports, settings, the sign-in env...)
1947
+ * has nothing to do with Lettermint and must keep working the moment a
1948
+ * mail domain was never even recorded for this org, the same as before
1949
+ * this field existed (review round 2, finding 3).
1764
1950
  */
1765
1951
  private ensureAppLettermintMail;
1766
1952
  /**
@@ -1796,6 +1982,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1796
1982
  * it the same way the app step does, rather than assuming it already
1797
1983
  * exists -- the domain must never end up unrestricted, whichever step gets
1798
1984
  * there first.
1985
+ *
1986
+ * #491: this org's own `<slug>-relay` project is unaffected by whether it
1987
+ * is the platform's own org -- only the domain restriction
1988
+ * (`platformApex`/`platformLettermintProjectId`, forwarded to
1989
+ * {@link ensureLettermintDomainRestriction}) and the delete loop below
1990
+ * (which must never delete the platform's own project) change.
1799
1991
  */
1800
1992
  private ensureOrgLettermintRelay;
1801
1993
  /**
@@ -1818,6 +2010,21 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1818
2010
  * unrestricted domain cannot verify and cannot send, whichever project
1819
2011
  * owns it (post-#140 review finding, restated here rather than only at
1820
2012
  * the one call site it used to have).
2013
+ *
2014
+ * #491: `platformApex`/`platformLettermintProjectId` cover both callers at
2015
+ * once -- when `mailDomain` is the platform's own apex, the platform's
2016
+ * project id is always folded into the restriction, so a plan that shares
2017
+ * the platform's project (rather than minting a `<slug>-app` one) never
2018
+ * ends up restricting that org's domain to nothing. A no-op for every
2019
+ * other org's domain, since `mailDomain` never matches `platformApex` there.
2020
+ * Review round 2, finding 4: a declared id that is not among `allProjects`
2021
+ * (a mistype, or the project was deleted in Lettermint) is refused rather
2022
+ * than folded in anyway -- that would restrict the apex to whatever *is*
2023
+ * live (the relay project, say) plus an id naming nothing, which excludes
2024
+ * the real platform project from its own domain exactly as completely as
2025
+ * never restricting it at all. Centralised here rather than duplicated at
2026
+ * each caller, since both {@link ensureAppLettermintMail} and
2027
+ * {@link ensureOrgLettermintRelay} reach the apex through this one method.
1821
2028
  */
1822
2029
  private ensureLettermintDomainRestriction;
1823
2030
  /**
@@ -2356,6 +2563,13 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2356
2563
  * domain recorded) and on a same-domain re-apply (compared
2357
2564
  * case-insensitively, the same rule {@link findExactLettermintDomain}
2358
2565
  * applies to the lookup itself).
2566
+ *
2567
+ * #491 review: also a no-op when the previous domain is the platform's own
2568
+ * apex -- an org that moves its mail *off* that domain (`usesDomain` flips,
2569
+ * or `org.domain` changes) must never delete it here, the same protection
2570
+ * {@link parkLettermintMail} already gives the apex on a move to `off`.
2571
+ * `planMail` only blocks a move *onto* the apex (no project id yet); a move
2572
+ * away from it reaches this method with nothing to block it otherwise.
2359
2573
  */
2360
2574
  private deleteStaleLettermintDomain;
2361
2575
  /**
@@ -2398,6 +2612,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2398
2612
  * A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
2399
2613
  * nothing was ever provisioned for an org whose mail went straight from
2400
2614
  * never-applied to off.
2615
+ *
2616
+ * #491: when `mailDomain` is the platform's own apex, this must never
2617
+ * delete the platform's own project (the delete loop skips it by id) or
2618
+ * the platform's own domain (returns before the domain lookup/delete).
2401
2619
  */
2402
2620
  private parkLettermintMail;
2403
2621
  /**
@@ -2497,6 +2715,22 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2497
2715
  * new key.
2498
2716
  */
2499
2717
  private revokeEntryKey;
2718
+ /**
2719
+ * F7 (#317): mints one entry's names key directly below the org (names
2720
+ * takes no separate minter key id, unlike ai/files), writes `NAMES_URL`
2721
+ * and `NAMES_API_KEY` to the entry's app env, then restarts it. Retires
2722
+ * the key when `uses.names` is removed. Follows the "one mint" rule
2723
+ * (CLAUDE.md "Platform steps"): the id is recorded as undelivered and
2724
+ * persisted before delivery is attempted, so a crash or a failed write
2725
+ * never leads to a second mint -- `planEntryNamesKeys` blocks instead.
2726
+ */
2727
+ private namesEntryKey;
2728
+ /**
2729
+ * F7 (#317): `plan/entry-names-key.ts`'s `planNamesReservedLabels` --
2730
+ * tells the names service every label this org's own entries already
2731
+ * hold, so names refuses a customer claiming one of them.
2732
+ */
2733
+ private namesReservedLabels;
2500
2734
  /**
2501
2735
  * F6b: mints or renews the org's own files key, or revokes the ones a
2502
2736
  * renewal replaced. The key is minted below the files organisation's owner