@wtfalch/foundry 0.7.1 → 0.9.0

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.
Files changed (54) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +12 -8
  2. package/dist/bootstrap/bootstrap.js +4 -12
  3. package/dist/bootstrap/declare.js +1 -5
  4. package/dist/bootstrap/versions.d.ts +8 -0
  5. package/dist/bootstrap/versions.js +31 -0
  6. package/dist/calls-sfu-deploy-support.d.ts +1 -1
  7. package/dist/calls-sfu-deploy-support.js +1 -1
  8. package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
  9. package/dist/calls-sfu-ssh-adapter.js +1 -1
  10. package/dist/calls-sfu.d.ts +5 -4
  11. package/dist/calls-sfu.js +5 -4
  12. package/dist/index.d.ts +4 -5
  13. package/dist/index.js +6 -3
  14. package/dist/migrations/0024_audit_events.sql +1 -1
  15. package/dist/migrations/0025_audit_display.sql +1 -1
  16. package/dist/migrations/0026_audit_erase_email_guard.sql +1 -1
  17. package/dist/migrations/0027_audit_chain.sql +1 -1
  18. package/dist/migrations/0028_audit_rls.sql +2 -2
  19. package/dist/migrations/0029_hosting_entries.sql +109 -0
  20. package/dist/migrations-dir.d.ts +9 -0
  21. package/dist/migrations-dir.js +10 -0
  22. package/dist/org.d.ts +59 -27
  23. package/dist/org.js +98 -26
  24. package/dist/plan/calls-matrix.d.ts +2 -0
  25. package/dist/plan/calls-matrix.js +2 -0
  26. package/dist/plan/diff.d.ts +3 -1
  27. package/dist/plan/diff.js +100 -32
  28. package/dist/plan/executor.d.ts +324 -19
  29. package/dist/plan/executor.js +764 -34
  30. package/dist/plan/index.d.ts +1 -1
  31. package/dist/plan/index.js +1 -1
  32. package/dist/plan/login-host.d.ts +24 -0
  33. package/dist/plan/login-host.js +21 -0
  34. package/dist/plan/plan-apply.d.ts +2 -0
  35. package/dist/plan/plan-apply.js +26 -10
  36. package/dist/plan/platform-diff.d.ts +102 -12
  37. package/dist/plan/platform-diff.js +236 -15
  38. package/dist/plan/platform-providers.d.ts +43 -1
  39. package/dist/plan/platform-providers.js +245 -33
  40. package/dist/plan/zitadel-system-api.d.ts +64 -0
  41. package/dist/plan/zitadel-system-api.js +125 -0
  42. package/dist/plan.d.ts +43 -0
  43. package/dist/plan.js +16 -1
  44. package/dist/platform.d.ts +1 -1
  45. package/dist/store/audit.d.ts +13 -17
  46. package/dist/store/audit.js +14 -18
  47. package/dist/store/postgres.d.ts +14 -0
  48. package/dist/store/postgres.js +15 -18
  49. package/dist/store/schema.d.ts +20 -1
  50. package/dist/store/schema.js +7 -1
  51. package/package.json +6 -3
  52. package/versions.json +1 -1
  53. package/dist/migrate.d.ts +0 -38
  54. package/dist/migrate.js +0 -47
@@ -282,6 +282,59 @@ export interface CoolifyPort {
282
282
  * deploy` method-for-method.
283
283
  */
284
284
  deploy(uuid: string, force?: boolean): Promise<unknown>;
285
+ /**
286
+ * #257: every S3-compatible storage registered for the team -- matches
287
+ * `CoolifyClient.listS3Storages` method-for-method. `provisionPlatformBackup`
288
+ * (`platform-providers.ts`) uses this to find the platform's shared R2
289
+ * bucket by name before registering a second one.
290
+ */
291
+ listS3Storages(): Promise<Array<{
292
+ uuid: string;
293
+ name: string;
294
+ bucket: string;
295
+ endpoint: string;
296
+ is_usable?: boolean;
297
+ }>>;
298
+ /**
299
+ * #257: registers a bucket as a Coolify team S3 storage -- matches
300
+ * `CoolifyClient.createS3Storage` method-for-method.
301
+ */
302
+ createS3Storage(input: {
303
+ name: string;
304
+ description?: string;
305
+ endpoint: string;
306
+ bucket: string;
307
+ region: string;
308
+ key: string;
309
+ secret: string;
310
+ is_usable?: boolean;
311
+ }): Promise<{
312
+ uuid: string;
313
+ }>;
314
+ /**
315
+ * #257: the scheduled backups of one database -- matches
316
+ * `CoolifyClient.listDatabaseBackups` method-for-method.
317
+ * `provisionPlatformBackup` reads this to decide whether a database
318
+ * already has an enabled, `save_s3` schedule before creating a second one.
319
+ */
320
+ listDatabaseBackups(databaseUuid: string): Promise<Array<{
321
+ uuid: string;
322
+ frequency: string;
323
+ enabled: boolean;
324
+ save_s3: boolean;
325
+ }>>;
326
+ /**
327
+ * #257: schedules dumps of a database to the registered S3 storage --
328
+ * matches `CoolifyClient.createDatabaseBackup` method-for-method.
329
+ */
330
+ createDatabaseBackup(databaseUuid: string, input: {
331
+ frequency: string;
332
+ enabled?: boolean;
333
+ save_s3?: boolean;
334
+ s3_storage_uuid?: string;
335
+ }): Promise<{
336
+ uuid: string;
337
+ }>;
285
338
  }
286
339
  /**
287
340
  * The Cloudflare calls this executor makes, shaped to match
@@ -344,11 +397,8 @@ export interface CloudflarePort {
344
397
  deleteAccessApp(appId: string): Promise<void>;
345
398
  /**
346
399
  * `FoundryPlanApply.destroy`'s teardown of any DNS record foundry created
347
- * for the org. Nothing in this executor creates or records one yet (Finding,
348
- * this campaign's report: no `cloudflare:dns` resource kind exists in
349
- * `diff.ts`/`executor.ts` today), so `destroy` has no dispatch target for
350
- * it either -- this port method exists ready for whichever step starts
351
- * creating one.
400
+ * for the org -- `destroy`'s `cloudflare:tunnel` case uses it to remove
401
+ * every CNAME {@link publishTunnelDnsRecords} published (#236).
352
402
  */
353
403
  deleteDnsRecord(zoneId: string, recordId: string): Promise<void>;
354
404
  /** `coolify:mail`'s lookup of an existing MX/TXT record before upserting it, filtered by type so a stale record of a different type is never mistaken for a match. */
@@ -358,6 +408,27 @@ export interface CloudflarePort {
358
408
  content: string;
359
409
  name: string;
360
410
  }>>;
411
+ /**
412
+ * #236: `destroy`'s `cloudflare:tunnel` case and
413
+ * {@link FoundryStepExecutor.deleteStaleTunnelDnsRecords} each fall back to
414
+ * this for a domain recorded before
415
+ * {@link CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY} existed, with no
416
+ * hostname list to look up instead -- the one case left that must still
417
+ * see every CNAME in the zone, not one matched by name. Round 3: this is
418
+ * no longer either caller's normal path, since the real adapter
419
+ * (`@wtfalch/providers`' `cloudflare.ts`) asks for one page of 100 and
420
+ * never walks `result_info.total_pages`, so a zone with more records than
421
+ * that would silently lose everything past the first page.
422
+ */
423
+ listDnsRecords(zoneId: string, opts?: {
424
+ type?: string;
425
+ name?: string;
426
+ }): Promise<Array<{
427
+ id: string;
428
+ type: string;
429
+ content: string;
430
+ name: string;
431
+ }>>;
361
432
  createDnsRecord(zoneId: string, rec: {
362
433
  type: string;
363
434
  name: string;
@@ -620,22 +691,56 @@ export interface ZitadelPort {
620
691
  }>;
621
692
  deleteApp(orgId: string, projectId: string, appId: string): Promise<void>;
622
693
  /**
623
- * `zitadelApp`'s kind-change path reads this to find an app a prior,
694
+ * `zitadelApp`'s kind-change path reads `apps` to find an app a prior,
624
695
  * lost-response create already made under the new name, before calling
625
696
  * `createOidcApp`/`createApiApp` again and orphaning that one under the
626
- * same name. Matches `@wtfalch/providers/zitadel`'s `ZitadelClient.
627
- * listApps` return shape exactly, so that client satisfies this port with
628
- * no adapter -- it does not carry `clientId`/`clientSecret` (that provider
697
+ * same name -- it does not carry `clientId`/`clientSecret` (that provider
629
698
  * method never parses either, and a secret is minted once, on creation,
630
699
  * never handed back again), which is why the reuse path below cannot
631
700
  * re-deliver either one.
632
- */
633
- listApps(orgId: string, projectId: string): Promise<Array<{
634
- id: string;
635
- name: string;
636
- appType: 'web' | 'native' | 'api';
637
- state: string;
638
- }>>;
701
+ *
702
+ * `loginBaseUri` and `complete` are `zitadelLoginHost`'s own #274
703
+ * additions, neither satisfied by the real (unpublished-ahead-of-pin)
704
+ * `@wtfalch/providers` `ZitadelClient.listApps` yet -- {@link
705
+ * adaptZitadelListApps} below is the adapter for both until a tracked
706
+ * change lands (https://github.com/wtfalch/providers/issues/29):
707
+ *
708
+ * - `loginBaseUri`: each app's current ZITADEL Login V2 base URI, used to
709
+ * decide whether a host a company-login-host change left behind is
710
+ * still in use -- rather than guessing from which apps this step itself
711
+ * repointed. Three states, not two (#274 fix round 4): a `string` is
712
+ * the URI itself; `null` means this call *read* the app and it has
713
+ * never had one set -- common, and never a reason to keep a host
714
+ * pending; `undefined` means this call could not read the field at all
715
+ * (every app, through {@link adaptZitadelListApps}, until the tracked
716
+ * change above lands) and is the only one of the three
717
+ * `zitadelLoginHost` fails a pending host closed over. Collapsing
718
+ * "confirmed unset" into "unreadable" would never let a host clear once
719
+ * an app with no login flow of its own existed, forever, even after the
720
+ * tracked change lands and genuinely-unset apps start reporting `null`.
721
+ * The real client's own `_search` response parsing (`appFrom`)
722
+ * deliberately reads only enough of each app to classify its kind,
723
+ * "everything else... ignored on purpose".
724
+ * - `complete`: whether this call read every one of ZITADEL's own result
725
+ * pages, not only the first -- the real client's `_search` call sends
726
+ * no `query.offset`/`limit` yet, so {@link adaptZitadelListApps} reports
727
+ * `false` until that lands. #274 fix round 3: a result count alone can
728
+ * never answer this (any threshold guessed at the boundary between
729
+ * "every app fit on one page" and "the page was cut off" is
730
+ * indistinguishable from the server's own page size, which nothing here
731
+ * is told), so `zitadelLoginHost` treats `complete: false` as "cannot
732
+ * safely tell" rather than trusting however many apps did come back.
733
+ */
734
+ listApps(orgId: string, projectId: string): Promise<{
735
+ apps: Array<{
736
+ id: string;
737
+ name: string;
738
+ appType: 'web' | 'native' | 'api';
739
+ state: string;
740
+ loginBaseUri?: string | null;
741
+ }>;
742
+ complete: boolean;
743
+ }>;
639
744
  /**
640
745
  * The org-owner flow (goal line): find-or-create the human user by email in
641
746
  * the org, then have ZITADEL email them an invite to set up a passkey.
@@ -779,6 +884,17 @@ export interface ZitadelPort {
779
884
  ensureTrustedDomain(domain: string): Promise<{
780
885
  added: boolean;
781
886
  }>;
887
+ /**
888
+ * #274: removes a host from the ZITADEL instance's trusted domains
889
+ * (`@wtfalch/providers`' `ZitadelClient.deleteTrustedDomain`) -- the old
890
+ * host a company login host change or an org destroy leaves behind, which
891
+ * {@link ensureTrustedDomain} only ever adds to, never removes from. No
892
+ * idempotency wrapper on the real client's own side (matching `deleteApp`/
893
+ * `deleteOrg`/`deleteProject`): a host already gone throws the same
894
+ * `status`-bearing not-found `isNotFoundError` catches everywhere else in
895
+ * this package.
896
+ */
897
+ deleteTrustedDomain(domain: string): Promise<void>;
782
898
  /**
783
899
  * #267: moves an already-created app's Login V2 base URI, read-modify-
784
900
  * write (`@wtfalch/providers`' `ZitadelClient.updateOidcAppLoginBaseUri`).
@@ -787,6 +903,36 @@ export interface ZitadelPort {
787
903
  */
788
904
  updateOidcAppLoginBaseUri(orgId: string, projectId: string, appId: string, loginBaseUri: string): Promise<void>;
789
905
  }
906
+ /**
907
+ * #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
908
+ * listApps` -- a plain array, with no `loginBaseUri` or completeness signal
909
+ * -- into {@link ZitadelPort.listApps}'s own shape, so a host wiring that
910
+ * real client in satisfies this port without reaching into `executor.ts`
911
+ * itself to build the wrapper by hand. `complete: false` on every call:
912
+ * the real client's own `_search` request has no pagination on it yet
913
+ * (https://github.com/wtfalch/providers/issues/29), so this adapter cannot
914
+ * promise it read more than one page. `loginBaseUri` comes back `undefined`
915
+ * for every app for the identical reason -- the real client does not read
916
+ * it back at all yet, not "read it back as absent" -- which is exactly the
917
+ * state `zitadelLoginHost`'s fail-closed rule exists for: every pending
918
+ * host stays pending until the tracked change above lands and this adapter
919
+ * (or its replacement) can start reporting `string | null` for real.
920
+ */
921
+ export declare function adaptZitadelListApps(client: {
922
+ listApps(orgId: string, projectId: string): Promise<ZitadelListAppsRawApp[]>;
923
+ }): ZitadelPort['listApps'];
924
+ /**
925
+ * The real `@wtfalch/providers` `ZitadelClient.listApps`' own return shape
926
+ * (its `ZitadelApp`) -- restated rather than imported, the same rule this
927
+ * package's doc comments already follow wherever a provider type is cited
928
+ * without a runtime dependency on the package that owns it existing yet.
929
+ */
930
+ export interface ZitadelListAppsRawApp {
931
+ id: string;
932
+ name: string;
933
+ appType: 'web' | 'native' | 'api';
934
+ state: string;
935
+ }
790
936
  /**
791
937
  * Provisions one org's organisation on Files (#80), matching
792
938
  * `files/packages/sdk/src/client.ts:136-145`'s
@@ -1052,7 +1198,7 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1052
1198
  } | undefined>;
1053
1199
  /**
1054
1200
  * `plan/diff.ts`'s `planAccess` (#84): puts the org's app hostname behind a
1055
- * Cloudflare Access app that lets only `hosting.accessEmails` through.
1201
+ * Cloudflare Access app that lets only the `app` entry's `accessEmails` through.
1056
1202
  * foundry names its Access app `foundry:<orgId>` and reuses only an app with
1057
1203
  * that name, so a re-run after a crash does not duplicate it. It refuses,
1058
1204
  * rather than adopt, when another Access app already holds the hostname:
@@ -1095,6 +1241,66 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1095
1241
  * domains, writes zitadel-login's binding, then repoints the apps whose
1096
1242
  * client id that binding holds. Apps created later are not in it yet:
1097
1243
  * the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
1244
+ *
1245
+ * #274: when the org's host has moved (`recorded[key]`, the *previous*
1246
+ * run's resourceId, differs from this run's `host`), also removes every
1247
+ * host that is no longer the org's current one from both places --
1248
+ * `ensureTrustedDomain` and `upsertBinding` above only ever add, so
1249
+ * without this an old host stays trusted and bound forever, and a later
1250
+ * org that reuses the old slug inherits it unasked.
1251
+ *
1252
+ * #274 fix round 2: this always returns `host` -- the *current* desired
1253
+ * host -- as its `resourceId`, never an old one, however many of its
1254
+ * removals are still outstanding. Fix round 1 returned `previousHost`
1255
+ * while a removal was skipped, banking on `planLoginHost` replanning the
1256
+ * whole step off that mismatch alone; that loses track of the *new* host
1257
+ * the moment a second host change moves `previousHost` on again (A -> B
1258
+ * skipped, then B -> C: A's own removal has nothing left recording it was
1259
+ * ever owed). `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY`
1260
+ * (`pendingRemovalHosts`/`pendingRemovalState`, `login-host.ts`) is the
1261
+ * durable record instead -- every host still awaiting removal, independent
1262
+ * of which one is current -- and `planLoginHost` replans the step whenever
1263
+ * it is non-empty, not only when the current host itself differs.
1264
+ *
1265
+ * Whether a host is still "in use" is decided from each live app's actual
1266
+ * ZITADEL Login V2 base URI (`ZitadelPort.listApps`' `loginBaseUri`), not
1267
+ * from `boundAppIds` membership: fix round 1 skipped removal only while an
1268
+ * app outside `boundAppIds` existed, but `boundAppIds` is deliberately
1269
+ * narrower than "every live app" (it excludes `native`/`api` apps and any
1270
+ * `web` app without a recorded client id yet) and a host can safely be
1271
+ * removed even while such an app exists, so long as nothing is actually
1272
+ * pointed at it. Removing by the wrong signal either breaks sign-in (an
1273
+ * app repointed is not the same fact as an app with no client id yet) or
1274
+ * strands a host forever (an org with a `native`/`api` app would never
1275
+ * satisfy "every live app bound").
1276
+ *
1277
+ * #274 fix round 3, four more defects fix round 2 left:
1278
+ *
1279
+ * 1. The *current* host is excluded from the candidates for removal.
1280
+ * Without this, A -> B (A pending, not yet safe) -> A deletes the host
1281
+ * this very call just (re-)trusted and bound: a host already in
1282
+ * `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY` from an earlier run can be
1283
+ * exactly the one the org's host is moving back to.
1284
+ * 2. Fail closed on missing information, rather than ever reading
1285
+ * "nothing uses it" off of one: an app whose `loginBaseUri` cannot be
1286
+ * read at all (`undefined` -- which, until a real adapter is wired per
1287
+ * `ZitadelPort.listApps`'s own doc comment, is every app, every call)
1288
+ * means every pending host stays pending this run, not just the ones a
1289
+ * narrower check happened to miss.
1290
+ * 3. Same fail-closed rule for `listApps`' own `complete` flag: an
1291
+ * incomplete read (today, always, for the identical reason) means the
1292
+ * set of apps this call saw could be missing one still pointed at a
1293
+ * pending host, so nothing is removed off of it either.
1294
+ * 4. A base URI compares after lowercasing and trimming its trailing
1295
+ * `/` (`normalizedLoginBaseUri`) -- ZITADEL's own value and the one
1296
+ * this step constructs are not guaranteed to agree on case or a
1297
+ * trailing slash, and a comparison that is case- or slash-sensitive
1298
+ * both ways can report "in use" as "not" by accident.
1299
+ * 5. Each live app's base URI is read as of *after* this call's own
1300
+ * repoint loop above, not the stale value `listApps` returned before
1301
+ * it: a bound app this very call just repointed away from a pending
1302
+ * host must not hold that host pending for one more run only because
1303
+ * the snapshot taken before the repoint still named it.
1098
1304
  */
1099
1305
  private zitadelLoginHost;
1100
1306
  /**
@@ -1178,7 +1384,7 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1178
1384
  * P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
1179
1385
  * call rather than folding into `pushDatabaseUrl`'s, since this one runs
1180
1386
  * unconditionally (`coolifyApp`) where that one runs only once
1181
- * `hosting.database` is declared and provisioned.
1387
+ * the `app` entry's `database` is declared and provisioned.
1182
1388
  *
1183
1389
  * `APP_URL` and `LOG_DIR` (#75) are pushed unconditionally; every other
1184
1390
  * value is pushed only once its own source step has actually recorded it
@@ -1191,7 +1397,7 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1191
1397
  * is its own design question, left out of #204's own "How" rather than
1192
1398
  * guessed) is out of scope here (task brief). `DATABASE_URL_OWNER`/
1193
1399
  * `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
1194
- * that call runs only once `hosting.database` is declared and
1400
+ * that call runs only once the `app` entry's `database` is declared and
1195
1401
  * provisioned, where this one is unconditional (#239).
1196
1402
  */
1197
1403
  private pushAppEnv;
@@ -1568,6 +1774,105 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1568
1774
  /** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
1569
1775
  private deliverWithRetry;
1570
1776
  private cloudflareTunnel;
1777
+ /**
1778
+ * #236: when this tunnel's DNS was last published under a domain that
1779
+ * differs from the one `org` now declares, deletes every CNAME in the
1780
+ * *old* domain's zone that points at this tunnel, so a domain switch
1781
+ * never leaves the old domain still answering for it. Takes the previous
1782
+ * domain and hostnames as plain arguments rather than reading them off
1783
+ * `context` itself (round 3): `cloudflareTunnel` reads the old values
1784
+ * before calling either this or `publishTunnelDnsRecords`, and (round 4
1785
+ * finding 3) does not record the new domain/hostnames until *after* this
1786
+ * method has returned, so a crash here still sees the old domain on retry.
1787
+ * A no-op when the old zone itself is already gone.
1788
+ *
1789
+ * `previousHostnames` (round 3) is {@link CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY}'s
1790
+ * recorded list, looked up one by one instead of a zone-wide
1791
+ * `listDnsRecords` -- the real adapter's version of that call asks for one
1792
+ * page and never walks the rest. A domain change recorded before that key
1793
+ * existed falls back to the old zone-wide pass, the one case where no
1794
+ * hostname list was ever written to look up instead.
1795
+ *
1796
+ * `previousIds` (round 4 finding 1, round 6 finding 2): the old domain's
1797
+ * CNAMEs were published under whatever tunnel id was live the *last*
1798
+ * time this step recorded that domain, which may be any id this step has
1799
+ * ever replaced, not necessarily `tunnelId` -- a tunnel recreated under
1800
+ * the same name (`findTunnelByName` returning a new id), possibly more
1801
+ * than once with no successful publish in between, in the same apply
1802
+ * that also changes the domain leaves the old domain's records pointed
1803
+ * at an old id. Matching only `tunnelId`'s target left them live
1804
+ * forever; matching only the single most recent previous id (round 4)
1805
+ * still missed an older one two unresolved recreates back. Every target
1806
+ * is checked so this stays a no-op once a retry has already moved the
1807
+ * old domain onto the current id.
1808
+ *
1809
+ * Round 6 finding 1: once `previousHostnames` is non-empty, every name in
1810
+ * it has now been looked up -- deleted (its CNAME matched one of
1811
+ * `dnsTargets`), left alone (found but foreign), or never found at all
1812
+ * (already gone) -- so none of them needs tracking under `previousDomain`
1813
+ * in {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} any longer; dropped
1814
+ * from that set accordingly. The zone-wide fallback path (no recorded
1815
+ * hostnames) never added entries of its own to begin with, so there is
1816
+ * nothing of this domain's left in that set either way.
1817
+ */
1818
+ private deleteStaleTunnelDnsRecords;
1819
+ /**
1820
+ * #236: upserts a proxied CNAME to this tunnel for each of
1821
+ * `tunnelIngressHostnames(org)` that actually belongs to `domain`'s own
1822
+ * zone -- a hosting app still served off the shared `*.oikos.host`
1823
+ * wildcard tunnel (`scripts/shared-ingress.mjs`) can appear in that list
1824
+ * alongside `agora.<domain>` (#164's unconditional Agora hostname) when
1825
+ * `hosting.usesDomain` is off, and `<slug>.oikos.host` is not this org's
1826
+ * zone to touch.
1827
+ *
1828
+ * Checks every hostname for a conflicting record *before* writing any
1829
+ * (round 2 finding: a conflict found only partway through a hostname-by-
1830
+ * hostname pass left earlier hostnames written but the domain never
1831
+ * recorded -- an orphaned partial write). An existing A or AAAA record,
1832
+ * or a CNAME already pointed anywhere but this tunnel or (round 3) its own
1833
+ * immediately prior tunnel id, blocks the whole step with a named
1834
+ * conflict rather than being adopted or replaced, the same do-not-adopt
1835
+ * policy {@link cloudflareZone} itself follows for the zone. A CNAME
1836
+ * already pointed at this tunnel is left alone, so a second apply changes
1837
+ * nothing. The caller ({@link cloudflareTunnel}) records the domain and the
1838
+ * returned hostname list as scope ids only once this method -- and, on a
1839
+ * domain change, the old domain's own cleanup -- has returned successfully
1840
+ * (round 4 finding 3), so a crash mid-write is recovered by re-running
1841
+ * this same idempotent step, never silently skipped.
1842
+ *
1843
+ * Round 3, round 6 finding 2: `previousIds` is every tunnel id this same
1844
+ * step has ever replaced (`cloudflareTunnel`'s own accumulated set,
1845
+ * captured before `tunnel` above is computed for this run) -- a tunnel
1846
+ * Cloudflare shows as recreated under the same name (`findTunnelByName`
1847
+ * returning a new id for `<slug>-tunnel`), possibly more than once with
1848
+ * no successful publish in between, leaves every CNAME still pointed at
1849
+ * one of those old ids. That is this org's own stale record, not a
1850
+ * foreign one, so it is repointed rather than refused, whichever of
1851
+ * `previousIds` it still names.
1852
+ *
1853
+ * Round 4 finding 2: `previousHostnames` is this domain's own previously
1854
+ * recorded hostname list (empty when the domain just changed --
1855
+ * {@link cloudflareTunnel} hands the *whole* old domain to
1856
+ * {@link deleteStaleTunnelDnsRecords} for that case instead). A name in it
1857
+ * that the org's current config no longer produces is dropped from
1858
+ * `hostnames` below but was never cleaned up before this round: its CNAME
1859
+ * stayed live and its name stayed in the recorded list forever, because
1860
+ * only a domain *change* ever ran cleanup. Each dropped name's CNAME is
1861
+ * deleted here when it still points at this tunnel or any of
1862
+ * `previousIds`; a name already gone, or now pointed at something
1863
+ * foreign, is simply left out of the returned list -- nothing here is
1864
+ * this method's to reclaim or report as a conflict.
1865
+ *
1866
+ * Round 5 finding 1, round 6 finding 1: `context` is used to add every
1867
+ * hostname this call confirms correct (settled, created or repointed) to
1868
+ * {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} as soon as it is
1869
+ * confirmed, and to drop a dropped hostname from it once that hostname's
1870
+ * own CNAME is resolved one way or another -- additively, alongside
1871
+ * whatever other domains that shared set already names, never
1872
+ * overwriting them. `destroy` and {@link deleteStaleTunnelDnsRecords}
1873
+ * both read this set.
1874
+ */
1875
+ private publishTunnelDnsRecords;
1571
1876
  /**
1572
1877
  * `plan/diff.ts`'s `planZone`: finds or creates the Cloudflare zone for the
1573
1878
  * org's own domain. Refuses to adopt a zone the store already records