@wtfalch/foundry 0.18.0 → 0.19.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.
@@ -1,6 +1,6 @@
1
1
  import type { CloudflareClient } from '@wtfalch/providers/cloudflare';
2
2
  import type { CoolifyClient } from '@wtfalch/providers/coolify';
3
- import type { ZitadelClient } from '@wtfalch/providers/zitadel';
3
+ import { type ZitadelClient } from '@wtfalch/providers/zitadel';
4
4
  import type { OrgId } from '../org.js';
5
5
  import type { DeclaredOrgStore } from '../store.js';
6
6
  import type { CloudflareReader, CmsReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
@@ -53,15 +53,17 @@ export type ScopeResolver = (orgId: OrgId) => Promise<string | null>;
53
53
  * resource it creates, keyed `system:kind:key` ({@link storeRecordedIds}
54
54
  * below reads those back), and #20's `FoundryStepExecutor` (`executor.ts`)
55
55
  * now also calls `StepExecutionContext.recordScopeId` for `coolify_project`,
56
- * `zitadel_org` and `zitadel_project`, so a resolver built with one of those
57
- * three `serviceKey`s returns the real id once that step has run. A
58
- * Cloudflare zone is the one scope id nothing writes yet: `executor.ts`'s
59
- * `cloudflareZone` records its result only under `cloudflare:zone:<domain>`
60
- * (a resource id, read back by {@link storeRecordedIds}), not under a plain
61
- * `cloudflare_zone` scope key — a resolver built with that `serviceKey`
62
- * still returns `null` for every org. That is the correct "nothing live yet"
63
- * answer: a reader passed `null` returns `[]`, which `computePlanDetail`
64
- * (`diff.ts`) already reads as "no live resource".
56
+ * `zitadel_org`, `zitadel_project` and (#510) `cloudflare_zone`, so a
57
+ * resolver built with one of those `serviceKey`s returns the real id once
58
+ * that step has run. Before #510, `cloudflareZone` recorded its result only
59
+ * under `cloudflare:zone:<domain>` (a resource id, read back by
60
+ * {@link storeRecordedIds}), never under the plain `cloudflare_zone` scope
61
+ * key — so a resolver built with that `serviceKey` always returned `null`,
62
+ * `ProvidersCloudflareReader.listResources` always returned `[]`, and
63
+ * `diff.ts`'s `planZone` re-proposed provisioning the zone on every apply
64
+ * even once it was live. A scope key that genuinely has nothing recorded yet
65
+ * still correctly resolves to `null`: a reader passed `null` returns `[]`,
66
+ * which `computePlanDetail` (`diff.ts`) already reads as "no live resource".
65
67
  */
66
68
  export declare function storeScopeResolver(store: DeclaredOrgStore, serviceKey: string): ScopeResolver;
67
69
  /**
@@ -112,7 +114,8 @@ export declare class ProvidersCloudflareReader implements CloudflareReader {
112
114
  /**
113
115
  * Backed by `@wtfalch/providers/zitadel`'s `ZitadelClient.listResources`: one
114
116
  * resource per project and per project grant in a ZITADEL org (v1 scope —
115
- * see that method's own doc comment).
117
+ * see that method's own doc comment), plus (#510) a synthesized `issuer`
118
+ * resource once the org's own ZITADEL org is confirmed live.
116
119
  */
117
120
  export declare class ProvidersZitadelReader implements ZitadelReader {
118
121
  private readonly client;
@@ -120,6 +123,26 @@ export declare class ProvidersZitadelReader implements ZitadelReader {
120
123
  private readonly resolveRecordedIds;
121
124
  constructor(client: ZitadelClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
122
125
  listResources(orgId: OrgId): Promise<LiveResource[]>;
126
+ /**
127
+ * #510: `client.listResources` reports only what is *inside* a ZITADEL org
128
+ * (its projects and grants), never the org record itself, so nothing ever
129
+ * matched `diff.ts`'s `planIssuer`'s own `zitadel:issuer:<orgId>` lookup —
130
+ * every plan re-proposed "provision the org's own ZITADEL org" after a
131
+ * clean apply, even once `zitadel_org` was recorded and live. `orgId` here
132
+ * is foundry's own id ({@link resourceKey}'s `key` for an issuer, `diff.ts`'s
133
+ * `planIssuer`), not `zitadelOrgId` — the resource foundry tracks is "this
134
+ * org's issuer", and ZITADEL's own org id is an implementation detail of
135
+ * where it lives, the same distinction {@link applyRecordedIds} draws for
136
+ * every other recorded resource.
137
+ *
138
+ * `resolveScope` only reads what was last recorded; it never confirms the
139
+ * org still exists in ZITADEL. A 404 here means the recorded id no longer
140
+ * resolves (the org was removed out of band) and reads as "not live" — the
141
+ * same `[]` answer a never-recorded scope already gives. Any other failure
142
+ * propagates, same as every other call in this reader. An org ZITADEL still
143
+ * returns but marks `ORG_STATE_REMOVED` is not live either.
144
+ */
145
+ private liveIssuer;
123
146
  }
124
147
  /**
125
148
  * Backed by the same `@wtfalch/providers/coolify`'s `CoolifyClient.listResources`
@@ -1,3 +1,4 @@
1
+ import { ZitadelError } from '@wtfalch/providers/zitadel';
1
2
  import { BOULE_MANAGED_ENV_KEYS } from './boule.js';
2
3
  /**
3
4
  * A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
@@ -8,15 +9,17 @@ import { BOULE_MANAGED_ENV_KEYS } from './boule.js';
8
9
  * resource it creates, keyed `system:kind:key` ({@link storeRecordedIds}
9
10
  * below reads those back), and #20's `FoundryStepExecutor` (`executor.ts`)
10
11
  * now also calls `StepExecutionContext.recordScopeId` for `coolify_project`,
11
- * `zitadel_org` and `zitadel_project`, so a resolver built with one of those
12
- * three `serviceKey`s returns the real id once that step has run. A
13
- * Cloudflare zone is the one scope id nothing writes yet: `executor.ts`'s
14
- * `cloudflareZone` records its result only under `cloudflare:zone:<domain>`
15
- * (a resource id, read back by {@link storeRecordedIds}), not under a plain
16
- * `cloudflare_zone` scope key — a resolver built with that `serviceKey`
17
- * still returns `null` for every org. That is the correct "nothing live yet"
18
- * answer: a reader passed `null` returns `[]`, which `computePlanDetail`
19
- * (`diff.ts`) already reads as "no live resource".
12
+ * `zitadel_org`, `zitadel_project` and (#510) `cloudflare_zone`, so a
13
+ * resolver built with one of those `serviceKey`s returns the real id once
14
+ * that step has run. Before #510, `cloudflareZone` recorded its result only
15
+ * under `cloudflare:zone:<domain>` (a resource id, read back by
16
+ * {@link storeRecordedIds}), never under the plain `cloudflare_zone` scope
17
+ * key — so a resolver built with that `serviceKey` always returned `null`,
18
+ * `ProvidersCloudflareReader.listResources` always returned `[]`, and
19
+ * `diff.ts`'s `planZone` re-proposed provisioning the zone on every apply
20
+ * even once it was live. A scope key that genuinely has nothing recorded yet
21
+ * still correctly resolves to `null`: a reader passed `null` returns `[]`,
22
+ * which `computePlanDetail` (`diff.ts`) already reads as "no live resource".
20
23
  */
21
24
  export function storeScopeResolver(store, serviceKey) {
22
25
  return async (orgId) => {
@@ -179,18 +182,34 @@ async function withEnvNamesConfig(client, resource, recorded) {
179
182
  * the `agora` kind, like {@link withLogVolumeConfig}. Unlike that read, a
180
183
  * failure here is not swallowed: a plan that cannot see the live config must
181
184
  * fail, not report it as missing.
185
+ *
186
+ * #506: also the live app's own `ports_exposes`, as `config.ports_exposes`,
187
+ * so `planAgora` can plan the fix for an app Coolify never got that setting
188
+ * on even when its env already matches -- the exact shape of the Company
189
+ * Included incident this issue names, where re-planning without this read
190
+ * would see no drift at all and never run `coolifyAgora` again.
182
191
  */
183
192
  async function withAgoraEnvConfig(client, resource, recorded) {
184
193
  if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('agora:')) {
185
194
  return resource;
186
195
  }
187
- const envs = await client.listEnvs(resource.key);
196
+ const [envs, app] = await Promise.all([
197
+ client.listEnvs(resource.key),
198
+ client.getApplication(resource.key),
199
+ ]);
188
200
  const env = {};
189
201
  for (const e of envs) {
190
202
  if (!e.is_preview && e.key.startsWith('AGORA_'))
191
203
  env[e.key] = e.value;
192
204
  }
193
- return { ...resource, config: { ...resource.config, env } };
205
+ // `@wtfalch/providers`' `CoolifyApplication` type does not declare
206
+ // `ports_exposes` (as of 0.7.3), but `getApplication` returns the raw
207
+ // Coolify API response (`coolify.ts`'s own `getApplication`, a plain
208
+ // `request` passthrough), which does carry it -- the same gap that
209
+ // repo's own CLAUDE.md names for other fields ("verified against a live
210
+ // instance, not the bundled openapi.yaml").
211
+ const portsExposes = app.ports_exposes;
212
+ return { ...resource, config: { ...resource.config, env, ports_exposes: portsExposes } };
194
213
  }
195
214
  /**
196
215
  * #161: the org's own recorded Boule app's live sign-in env vars, as
@@ -273,13 +292,17 @@ export class ProvidersCloudflareReader {
273
292
  // A zone needs no mapping: its key is already the zone name, which is
274
293
  // foundry's own zone key (`diff.ts`'s `planZone`), so it never matches a
275
294
  // recorded provider id and always passes through unchanged.
276
- return applyRecordedIds(toFoundryResources('cloudflare', resources), recorded, CLOUDFLARE_DROP_UNRECORDED);
295
+ return applyRecordedIds(toFoundryResources('cloudflare', resources)
296
+ // Keyed by the zone id, so a recorded zone id would map it onto the
297
+ // zone's own key and replace the zone's status; nothing plans from it.
298
+ .filter((resource) => resource.kind !== 'email_routing'), recorded, CLOUDFLARE_DROP_UNRECORDED);
277
299
  }
278
300
  }
279
301
  /**
280
302
  * Backed by `@wtfalch/providers/zitadel`'s `ZitadelClient.listResources`: one
281
303
  * resource per project and per project grant in a ZITADEL org (v1 scope —
282
- * see that method's own doc comment).
304
+ * see that method's own doc comment), plus (#510) a synthesized `issuer`
305
+ * resource once the org's own ZITADEL org is confirmed live.
283
306
  */
284
307
  export class ProvidersZitadelReader {
285
308
  client;
@@ -294,11 +317,45 @@ export class ProvidersZitadelReader {
294
317
  const zitadelOrgId = await this.resolveScope(orgId);
295
318
  if (!zitadelOrgId)
296
319
  return [];
297
- const [resources, recorded] = await Promise.all([
320
+ const [resources, recorded, issuer] = await Promise.all([
298
321
  this.client.listResources(zitadelOrgId),
299
322
  this.resolveRecordedIds(orgId),
323
+ this.liveIssuer(orgId, zitadelOrgId),
300
324
  ]);
301
- return applyRecordedIds(toFoundryResources('zitadel', resources), recorded);
325
+ const mapped = applyRecordedIds(toFoundryResources('zitadel', resources), recorded);
326
+ return issuer ? [...mapped, issuer] : mapped;
327
+ }
328
+ /**
329
+ * #510: `client.listResources` reports only what is *inside* a ZITADEL org
330
+ * (its projects and grants), never the org record itself, so nothing ever
331
+ * matched `diff.ts`'s `planIssuer`'s own `zitadel:issuer:<orgId>` lookup —
332
+ * every plan re-proposed "provision the org's own ZITADEL org" after a
333
+ * clean apply, even once `zitadel_org` was recorded and live. `orgId` here
334
+ * is foundry's own id ({@link resourceKey}'s `key` for an issuer, `diff.ts`'s
335
+ * `planIssuer`), not `zitadelOrgId` — the resource foundry tracks is "this
336
+ * org's issuer", and ZITADEL's own org id is an implementation detail of
337
+ * where it lives, the same distinction {@link applyRecordedIds} draws for
338
+ * every other recorded resource.
339
+ *
340
+ * `resolveScope` only reads what was last recorded; it never confirms the
341
+ * org still exists in ZITADEL. A 404 here means the recorded id no longer
342
+ * resolves (the org was removed out of band) and reads as "not live" — the
343
+ * same `[]` answer a never-recorded scope already gives. Any other failure
344
+ * propagates, same as every other call in this reader. An org ZITADEL still
345
+ * returns but marks `ORG_STATE_REMOVED` is not live either.
346
+ */
347
+ async liveIssuer(orgId, zitadelOrgId) {
348
+ try {
349
+ const found = await this.client.getOrg(zitadelOrgId);
350
+ if (found.state === 'ORG_STATE_REMOVED')
351
+ return null;
352
+ }
353
+ catch (err) {
354
+ if (err instanceof ZitadelError && err.status === 404)
355
+ return null;
356
+ throw err;
357
+ }
358
+ return { system: 'zitadel', kind: 'issuer', key: orgId, config: {} };
302
359
  }
303
360
  }
304
361
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.18.0",
3
+ "version": "0.19.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",