@wtfalch/foundry 0.25.1 → 0.26.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.
@@ -117,6 +117,15 @@ export declare function toPlan(detail: PlanDetail): Plan;
117
117
  * against this seam.
118
118
  */
119
119
  export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[], recordedIds?: Readonly<Record<string, string>>, agoraHost?: AgoraHostReading, jobsAvailable?: boolean, platformReadiness?: OrgPlatformReadiness, options?: PlanOrgOptions): PlanDetail;
120
+ /**
121
+ * #488: the bare hostname out of an issuer URL (`https://<hostname>`, the
122
+ * only shape {@link agoraHostValuesFromPlatform} or a host-supplied
123
+ * `AgoraHostPort` ever hands back), or `null` when there is no issuer to
124
+ * read one from. What {@link loginHostnameFor} compares the org's own
125
+ * computed `auth.<domain>` against, so the platform's own org never plans
126
+ * its login host equal to the shared ZITADEL instance's own.
127
+ */
128
+ export declare function isPlatformOwnOrg(org: DeclaredOrg, platformApex: string | null): boolean;
120
129
  /** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
121
130
  export declare const AI_KEY_UNDELIVERED_SCOPE_KEY = "ai_key_undelivered";
122
131
  /** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
package/dist/plan/diff.js CHANGED
@@ -734,7 +734,7 @@ function planOwners(org, recordedIds, authReady, authIssuer) {
734
734
  * computed `auth.<domain>` against, so the platform's own org never plans
735
735
  * its login host equal to the shared ZITADEL instance's own.
736
736
  */
737
- function isPlatformOwnOrg(org, platformApex) {
737
+ export function isPlatformOwnOrg(org, platformApex) {
738
738
  return Boolean(org.domain && platformApex && org.domain.toLowerCase() === platformApex.toLowerCase());
739
739
  }
740
740
  function platformAuthHostnameFrom(issuer) {
@@ -71,7 +71,9 @@ export interface HealthTarget {
71
71
  * the broker whenever the org has them; then whichever of hosting or site
72
72
  * the org runs, if either.
73
73
  */
74
- export declare function healthTargets(org: DeclaredOrg): HealthTarget[];
74
+ export declare function healthTargets(org: DeclaredOrg, options?: {
75
+ platform?: DeclaredPlatform | null;
76
+ }): HealthTarget[];
75
77
  /**
76
78
  * The seven platform services #541 names (`platform:auth`, `platform:archon`,
77
79
  * `platform:mail`, `platform:ai`, `platform:files`, `platform:integrations`,
@@ -1,6 +1,7 @@
1
1
  import { appEntry } from '../org.js';
2
2
  import { agoraHostnameFor } from './agora.js';
3
3
  import { bouleHostnameFor } from './boule.js';
4
+ import { isPlatformOwnOrg } from './diff.js';
4
5
  import { callsHostnameFor, cmsHostnameFor } from './executor.js';
5
6
  import { isBehindGate } from './gates.js';
6
7
  import { entryHostnames } from './hostnames.js';
@@ -12,8 +13,6 @@ const DEFAULT_API_HEALTH_PATH = '/api/health';
12
13
  const STATIC_PAGE_PATH = '/';
13
14
  /** zitadel-login's own readiness route (#541's "Real paths today"). `depth: 'shallow'`, not `'deep'`: login holds no database of its own (`login-host.ts`), whatever its name suggests. */
14
15
  const LOGIN_READY_PATH = '/ready';
15
- /** The broker answers on `platform.auth.hostname` under `AUTH_BROKER_PATH_PATTERN` (`platform-providers.ts`, `'^/api(/|$)'`), so its own health path carries that prefix. */
16
- const BROKER_HEALTH_PATH = '/api/healthy';
17
16
  /** `platform-providers.ts`'s own `waitForInstance` call target (`` `https://${hostname}/debug/healthz}` ``), also the one path `AUTH_ZITADEL_PATH_PATTERN` names by name. */
18
17
  const ZITADEL_HEALTH_PATH = '/debug/healthz';
19
18
  /**
@@ -26,7 +25,12 @@ const STALWART_READY_PATH = '/healthz/ready';
26
25
  const CHAT_SERVICE_HEALTH_PATH = '/_chat/health';
27
26
  const CALLS_HEALTH_PATH = '/healthz';
28
27
  /** Boule, Agora and the company's own login host: every org gets these, with no flag to check (the "Why" this issue opens with). */
29
- function alwaysOnTargets(org) {
28
+ function alwaysOnTargets(org, platform) {
29
+ // The platform's own org signs in on the shared instance host, not its own
30
+ // `loginHostnameFor` (#488; `login-host.ts`, `diff.ts`'s `planLoginHost`).
31
+ const loginHost = platform && isPlatformOwnOrg(org, platform.apex)
32
+ ? platform.auth.hostname
33
+ : loginHostnameFor(org);
30
34
  return [
31
35
  {
32
36
  service: 'boule',
@@ -49,7 +53,7 @@ function alwaysOnTargets(org) {
49
53
  },
50
54
  {
51
55
  service: 'login',
52
- url: `https://${loginHostnameFor(org)}${LOGIN_READY_PATH}`,
56
+ url: `https://${loginHost}${LOGIN_READY_PATH}`,
53
57
  expect: okExpect(),
54
58
  depth: 'shallow',
55
59
  restartSafe: false,
@@ -224,8 +228,8 @@ function appTargets(org) {
224
228
  * the broker whenever the org has them; then whichever of hosting or site
225
229
  * the org runs, if either.
226
230
  */
227
- export function healthTargets(org) {
228
- const targets = alwaysOnTargets(org);
231
+ export function healthTargets(org, options = {}) {
232
+ const targets = alwaysOnTargets(org, options.platform ?? null);
229
233
  const broker = brokerTarget(org);
230
234
  if (broker)
231
235
  targets.push(broker);
@@ -265,12 +269,18 @@ function authTargets(platform) {
265
269
  behindAccess: false,
266
270
  },
267
271
  {
272
+ // The broker's only unauthenticated route is `/healthy` at its own root
273
+ // (auth's `broker/src/server.ts`), but the tunnel hands it only
274
+ // `AUTH_BROKER_PATH_PATTERN` ('^/api(/|$)') unchanged: `/api/healthy`
275
+ // reaches the broker and answers 401, and bare `/healthy` reaches
276
+ // zitadel-login instead, which would read as the broker being up.
268
277
  service: 'auth-broker',
269
- url: `https://${hostname}${BROKER_HEALTH_PATH}`,
278
+ url: null,
270
279
  expect: okExpect(),
271
280
  depth: 'shallow',
272
281
  restartSafe: false,
273
282
  behindAccess: false,
283
+ reason: "the broker's health route (/healthy) is not reachable from outside: the tunnel sends it only /api/*, which answers 401 without a session, and /healthy on this host is zitadel-login's",
274
284
  },
275
285
  ];
276
286
  }
@@ -302,11 +312,15 @@ export function platformHealthTargets(platform) {
302
312
  ...authTargets(platform),
303
313
  {
304
314
  service: 'archon',
315
+ // `provisionPlatformArchon` always puts a Cloudflare Access app in front
316
+ // of this hostname (it throws with no Access emails), so a prober with no
317
+ // service token reads a redirect: it proves Access and the tunnel are up,
318
+ // not that Archon answers.
305
319
  url: `https://${platform.archon.hostname}${DEFAULT_API_HEALTH_PATH}`,
306
320
  expect: okExpect(),
307
- depth: 'deep',
321
+ depth: 'shallow',
308
322
  restartSafe: false,
309
- behindAccess: false,
323
+ behindAccess: true,
310
324
  },
311
325
  {
312
326
  service: 'mail',
@@ -197,6 +197,21 @@ export interface PlatformStepExecutionContext {
197
197
  */
198
198
  persist?(): Promise<void>;
199
199
  }
200
+ /**
201
+ * The flags {@link PlatformStepExecutor.driftedSteps} returns. Each one is
202
+ * described on `PlanPlatformOptions.drifted` (`platform-diff.ts`).
203
+ */
204
+ export interface PlatformDriftedSteps {
205
+ archon?: boolean;
206
+ auth?: boolean;
207
+ /** The host sets it. */
208
+ edge?: boolean;
209
+ /** #380 */
210
+ files?: boolean;
211
+ backupStorage?: boolean;
212
+ /** #257 fix round 4 */
213
+ backupStorageBlocked?: string;
214
+ }
200
215
  /** {@link StepExecutor}, for the platform (#167) rather than an org. */
201
216
  export interface PlatformStepExecutor {
202
217
  /**
@@ -235,17 +250,12 @@ export interface PlatformStepExecutor {
235
250
  }>;
236
251
  /**
237
252
  * Steps whose live state differs from the declared one in a way the recorded
238
- * ids cannot show, read from the live system on each plan.
239
- */
240
- driftedSteps?(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<{
241
- archon?: boolean;
242
- auth?: boolean;
243
- /** #380: see `PlanPlatformOptions.drifted.files` (`platform-diff.ts`). */
244
- files?: boolean;
245
- backupStorage?: boolean;
246
- /** #257 fix round 4: see `PlanPlatformOptions.drifted.backupStorageBlocked` (`platform-diff.ts`). */
247
- backupStorageBlocked?: string;
248
- }>;
253
+ * ids cannot show, read from the live system on each plan. The host sets
254
+ * `edge` (foundry's own executor never does). Foundry stores nothing about
255
+ * the token and the edge step does not clear the flag, so the host must stop
256
+ * reporting it once edge holds the new token and the old one is gone.
257
+ */
258
+ driftedSteps?(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<PlatformDriftedSteps>;
249
259
  /**
250
260
  * Gives the platform files runner a changed share-domain set and touches
251
261
  * nothing else. Returns the scope ids to record (only the share-domains
@@ -1008,11 +1008,17 @@ export interface PlanPlatformOptions {
1008
1008
  * retried forever. `files` (#380): the runner's live env is missing a
1009
1009
  * setting `ensureStarted()` requires -- see `filesRunnerEnvDrifted`
1010
1010
  * (`platform-providers.ts`) -- which the recorded hostname/gitRef alone
1011
- * could never show.
1011
+ * could never show. `edge`: the Cloudflare token edge runs on is not the one
1012
+ * the host now wants (an older scope), so the recorded ids say edge is
1013
+ * applied while it still holds the old token. The host sets it; foundry's
1014
+ * own executor never does. Foundry stores nothing about the token and the
1015
+ * edge step does not clear the flag, so the host must stop reporting it once
1016
+ * edge holds the new token and the old one is gone.
1012
1017
  */
1013
1018
  drifted?: {
1014
1019
  archon?: boolean;
1015
1020
  auth?: boolean;
1021
+ edge?: boolean;
1016
1022
  files?: boolean;
1017
1023
  backupStorage?: boolean;
1018
1024
  backupStorageBlocked?: string;
@@ -1061,7 +1061,7 @@ export function planPlatform(platform, live, options = {}) {
1061
1061
  // through it; it needs none of them (a Coolify database and app, its own
1062
1062
  // tunnel and DNS record, a held Cloudflare token) and records the address
1063
1063
  // they reach it at.
1064
- const edge = planEdge(platform, recordedIds, blocked.edge);
1064
+ const edge = planEdge(platform, recordedIds, blocked.edge, drifted.edge);
1065
1065
  if (edge.step)
1066
1066
  plannedSteps.push(edge.step);
1067
1067
  // Auth, mail and archon would build their tunnels and then fail at the DNS
@@ -2108,13 +2108,17 @@ function planFiles(platform, recordedIds, executorBlocked, authReady, drifted) {
2108
2108
  * Coolify app. A changed hostname or `gitRef` plans the step again (a
2109
2109
  * redeploy, no new secret).
2110
2110
  *
2111
+ * `drifted` (the host's own `driftedSteps`: edge holds an older Cloudflare
2112
+ * token) plans the same step on an applied edge. The step writes the held
2113
+ * token into edge's env and redeploys it.
2114
+ *
2111
2115
  * Blocked, not planned: a platform key minted but never delivered (lost,
2112
2116
  * `ServiceError`-free the way {@link planFiles}'s own management-key check
2113
2117
  * is), or `executorBlocked` -- the host built no edge dependencies, or
2114
2118
  * (CLAUDE.md's "Human steps") no Cloudflare API token is held yet under
2115
2119
  * {@link PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF}.
2116
2120
  */
2117
- function planEdge(platform, recordedIds, executorBlocked) {
2121
+ function planEdge(platform, recordedIds, executorBlocked, drifted) {
2118
2122
  if (!platform.edge.enabled)
2119
2123
  return { step: null, blocked: null };
2120
2124
  const undelivered = recordedIds[PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY];
@@ -2129,7 +2133,7 @@ function planEdge(platform, recordedIds, executorBlocked) {
2129
2133
  recordedIds[PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY] === platform.edge.gitRef &&
2130
2134
  // F4.dns: an edge applied before its address was recorded runs once more to record it.
2131
2135
  Boolean(recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]);
2132
- if (applied)
2136
+ if (applied && !drifted)
2133
2137
  return { step: null, blocked: null };
2134
2138
  if (executorBlocked)
2135
2139
  return { step: null, blocked: executorBlocked };
@@ -2142,7 +2146,9 @@ function planEdge(platform, recordedIds, executorBlocked) {
2142
2146
  desired: { hostname: platform.edge.hostname, gitRef: platform.edge.gitRef },
2143
2147
  },
2144
2148
  step: {
2145
- description: `provision the edge host at ${platform.edge.hostname}, with its own database and foundry's platform key`,
2149
+ description: applied
2150
+ ? `replace the Cloudflare token edge runs on at ${platform.edge.hostname} and redeploy edge`
2151
+ : `provision the edge host at ${platform.edge.hostname}, with its own database and foundry's platform key`,
2146
2152
  destroys: false,
2147
2153
  migrates: false,
2148
2154
  },
@@ -9,7 +9,7 @@ import type { DnsPort } from './edge-dns.js';
9
9
  import type { TunnelPort } from './edge-tunnels.js';
10
10
  import type { CloudflarePort, CoolifyPort, FoundryStepExecutorPorts, ZitadelPort } from './executor.js';
11
11
  import type { LiveResource, PlatformCloudflareReader, PlatformCoolifyReader } from './live.js';
12
- import type { PlatformStepExecutionContext, PlatformStepExecutor } from './plan-apply.js';
12
+ import type { PlatformDriftedSteps, PlatformStepExecutionContext, PlatformStepExecutor } from './plan-apply.js';
13
13
  import { type BrokerDeployRecord } from './platform-diff.js';
14
14
  import { type CallsSfuPlatformDeps } from './platform-sfu.js';
15
15
  import { type PlatformUptimeDeps } from './uptime-worker.js';
@@ -335,13 +335,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
335
335
  * login-client token is no longer the sole live PAT on its ZITADEL machine
336
336
  * user.
337
337
  */
338
- driftedSteps(platform: DeclaredPlatform, recordedIds?: Readonly<Record<string, string>>): Promise<{
339
- archon?: boolean;
340
- auth?: boolean;
341
- files?: boolean;
342
- backupStorage?: boolean;
343
- backupStorageBlocked?: string;
344
- }>;
338
+ driftedSteps(platform: DeclaredPlatform, recordedIds?: Readonly<Record<string, string>>): Promise<PlatformDriftedSteps>;
345
339
  /**
346
340
  * Gives the files runner a changed share-domain set and nothing else: its
347
341
  * Coolify `domains`, `FILES_PUBLIC_HOSTS` and one redeploy of the runner.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.25.1",
3
+ "version": "0.26.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",