@wtfalch/foundry 0.6.0 → 0.8.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 (58) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +12 -8
  2. package/dist/bootstrap/bootstrap.js +4 -12
  3. package/dist/bootstrap/declare.d.ts +3 -1
  4. package/dist/bootstrap/declare.js +6 -2
  5. package/dist/bootstrap/versions.d.ts +25 -0
  6. package/dist/bootstrap/versions.js +43 -0
  7. package/dist/calls-sfu-deploy-support.d.ts +1 -1
  8. package/dist/calls-sfu-deploy-support.js +1 -1
  9. package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
  10. package/dist/calls-sfu-ssh-adapter.js +1 -1
  11. package/dist/calls-sfu.d.ts +8 -6
  12. package/dist/calls-sfu.js +9 -6
  13. package/dist/index.d.ts +3 -4
  14. package/dist/index.js +5 -2
  15. package/dist/migrations/0022_chat_enabled.sql +6 -0
  16. package/dist/migrations/0023_hosting_app_env.sql +8 -0
  17. package/dist/migrations/0024_audit_events.sql +211 -0
  18. package/dist/migrations/0025_audit_display.sql +147 -0
  19. package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
  20. package/dist/migrations/0027_audit_chain.sql +170 -0
  21. package/dist/migrations/0028_audit_rls.sql +111 -0
  22. package/dist/migrations-dir.d.ts +9 -0
  23. package/dist/migrations-dir.js +10 -0
  24. package/dist/org.d.ts +24 -0
  25. package/dist/org.js +13 -0
  26. package/dist/plan/agora.d.ts +21 -0
  27. package/dist/plan/agora.js +23 -0
  28. package/dist/plan/calls-matrix.d.ts +4 -2
  29. package/dist/plan/calls-matrix.js +5 -2
  30. package/dist/plan/cloudflared-connector.d.ts +3 -2
  31. package/dist/plan/cloudflared-connector.js +5 -3
  32. package/dist/plan/diff.d.ts +18 -3
  33. package/dist/plan/diff.js +369 -42
  34. package/dist/plan/executor.d.ts +378 -44
  35. package/dist/plan/executor.js +816 -57
  36. package/dist/plan/index.d.ts +2 -0
  37. package/dist/plan/index.js +1 -0
  38. package/dist/plan/login-host.d.ts +88 -0
  39. package/dist/plan/login-host.js +73 -0
  40. package/dist/plan/plan-apply.d.ts +23 -8
  41. package/dist/plan/plan-apply.js +30 -10
  42. package/dist/plan/platform-diff.d.ts +102 -2
  43. package/dist/plan/platform-diff.js +232 -1
  44. package/dist/plan/platform-providers.d.ts +37 -2
  45. package/dist/plan/platform-providers.js +223 -36
  46. package/dist/plan/providers-readers.js +44 -1
  47. package/dist/plan.d.ts +43 -0
  48. package/dist/plan.js +16 -1
  49. package/dist/store/audit.d.ts +36 -4
  50. package/dist/store/audit.js +110 -29
  51. package/dist/store/postgres.d.ts +14 -0
  52. package/dist/store/postgres.js +17 -9
  53. package/dist/store/schema.d.ts +37 -1
  54. package/dist/store/schema.js +7 -0
  55. package/package.json +10 -5
  56. package/versions.json +8 -0
  57. package/dist/migrate.d.ts +0 -38
  58. package/dist/migrate.js +0 -47
@@ -2,6 +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 { StepAction } from './diff.js';
5
+ import { type LoginHostBindingsPort } from './login-host.js';
5
6
  import { type MailCopyPort } from './mail-copy.js';
6
7
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
7
8
  /**
@@ -140,13 +141,14 @@ export interface CoolifyPort {
140
141
  }>>;
141
142
  /**
142
143
  * Upsert environment variables on an application in one call — used here to
143
- * push the database's `internal_db_url` in as the app's `DATABASE_URL`
144
- * (hosting-wiring campaign), and, for the cms app, its `DATABASE_URL_OWNER`
145
- * plus a freshly minted `DATABASE_URL` runtime-role credential (#117). Each
146
- * value is a live credential: it must reach this call and nothing else —
147
- * never a step's return value, `serviceIds` (hence the store), an audit
148
- * record, a log or a thrown error. See `coolifyApp`'s `pushDatabaseUrl` and
149
- * `cmsSite`'s `ensureCmsRuntimeUrl`.
144
+ * push the org's own app's `DATABASE_URL_OWNER` plus a freshly minted
145
+ * `DATABASE_URL` runtime-role credential (hosting-wiring campaign, split
146
+ * per #239 the same way #117 already splits the cms app's), and likewise
147
+ * for the cms app itself. Each value is a live credential: it must reach
148
+ * this call and nothing else — never a step's return value, `serviceIds`
149
+ * (hence the store), an audit record, a log or a thrown error. See
150
+ * `coolifyApp`'s `pushDatabaseUrl`/`ensureAppRuntimeUrl` and `cmsSite`'s
151
+ * `ensureCmsRuntimeUrl`.
150
152
  */
151
153
  bulkUpsertEnvs(applicationUuid: string, envs: Array<{
152
154
  key: string;
@@ -280,6 +282,59 @@ export interface CoolifyPort {
280
282
  * deploy` method-for-method.
281
283
  */
282
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
+ }>;
283
338
  }
284
339
  /**
285
340
  * The Cloudflare calls this executor makes, shaped to match
@@ -342,11 +397,8 @@ export interface CloudflarePort {
342
397
  deleteAccessApp(appId: string): Promise<void>;
343
398
  /**
344
399
  * `FoundryPlanApply.destroy`'s teardown of any DNS record foundry created
345
- * for the org. Nothing in this executor creates or records one yet (Finding,
346
- * this campaign's report: no `cloudflare:dns` resource kind exists in
347
- * `diff.ts`/`executor.ts` today), so `destroy` has no dispatch target for
348
- * it either -- this port method exists ready for whichever step starts
349
- * creating one.
400
+ * for the org -- `destroy`'s `cloudflare:tunnel` case uses it to remove
401
+ * every CNAME {@link publishTunnelDnsRecords} published (#236).
350
402
  */
351
403
  deleteDnsRecord(zoneId: string, recordId: string): Promise<void>;
352
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. */
@@ -356,6 +408,27 @@ export interface CloudflarePort {
356
408
  content: string;
357
409
  name: string;
358
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
+ }>>;
359
432
  createDnsRecord(zoneId: string, rec: {
360
433
  type: string;
361
434
  name: string;
@@ -618,22 +691,56 @@ export interface ZitadelPort {
618
691
  }>;
619
692
  deleteApp(orgId: string, projectId: string, appId: string): Promise<void>;
620
693
  /**
621
- * `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,
622
695
  * lost-response create already made under the new name, before calling
623
696
  * `createOidcApp`/`createApiApp` again and orphaning that one under the
624
- * same name. Matches `@wtfalch/providers/zitadel`'s `ZitadelClient.
625
- * listApps` return shape exactly, so that client satisfies this port with
626
- * no adapter -- it does not carry `clientId`/`clientSecret` (that provider
697
+ * same name -- it does not carry `clientId`/`clientSecret` (that provider
627
698
  * method never parses either, and a secret is minted once, on creation,
628
699
  * never handed back again), which is why the reuse path below cannot
629
700
  * re-deliver either one.
630
- */
631
- listApps(orgId: string, projectId: string): Promise<Array<{
632
- id: string;
633
- name: string;
634
- appType: 'web' | 'native' | 'api';
635
- state: string;
636
- }>>;
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
+ }>;
637
744
  /**
638
745
  * The org-owner flow (goal line): find-or-create the human user by email in
639
746
  * the org, then have ZITADEL email them an invite to set up a passkey.
@@ -766,6 +873,65 @@ export interface ZitadelPort {
766
873
  * reports no longer live is never attempted at all.
767
874
  */
768
875
  listLoginClientPats(orgId: string, userId: string): Promise<string[]>;
876
+ /**
877
+ * #267: adds the org's own company login host to the ZITADEL instance's
878
+ * trusted domains (`@wtfalch/providers`' `ZitadelClient.ensureTrustedDomain`)
879
+ * -- the functionally critical half of `zitadelLoginHost` below, per
880
+ * `auth/docs/company-login-hosts.md`'s enforcement section: "the issuer
881
+ * refuses a public host that is not one of the instance's trusted
882
+ * domains." Idempotent: lists first, adds only if not already trusted.
883
+ */
884
+ ensureTrustedDomain(domain: string): Promise<{
885
+ added: boolean;
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>;
898
+ /**
899
+ * #267: moves an already-created app's Login V2 base URI, read-modify-
900
+ * write (`@wtfalch/providers`' `ZitadelClient.updateOidcAppLoginBaseUri`).
901
+ * `zitadelLoginHost` below calls this for each live app whose client id
902
+ * the binding holds; `zitadelApp` never sets it at creation.
903
+ */
904
+ updateOidcAppLoginBaseUri(orgId: string, projectId: string, appId: string, loginBaseUri: string): Promise<void>;
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;
769
935
  }
770
936
  /**
771
937
  * Provisions one org's organisation on Files (#80), matching
@@ -959,6 +1125,15 @@ export interface FoundryStepExecutorPorts {
959
1125
  * step throws. The planner blocks these steps first while a key is not held.
960
1126
  */
961
1127
  platformOrg?: PlatformOrgPortSource;
1128
+ /**
1129
+ * #267: zitadel-login's own `LOGIN_HOST_BINDINGS` entry for the org -- see
1130
+ * {@link LoginHostBindingsPort}'s own doc comment. Optional the same way
1131
+ * `jobsScheduler`/`mailCopy` are: a host that has not wired this yet gets no
1132
+ * login-host step at all -- the planner reports it blocked (the host also
1133
+ * sets `FoundryPlanApplyOptions.loginHostBindings`), so nothing is trusted
1134
+ * or repointed without the binding.
1135
+ */
1136
+ loginHostBindings?: LoginHostBindingsPort;
962
1137
  /**
963
1138
  * Hands a just-minted, one-time secret (today: an API app's
964
1139
  * `clientSecret`) to the host, immediately after the provider returns it
@@ -1060,6 +1235,74 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1060
1235
  * recorded).
1061
1236
  */
1062
1237
  private zitadelOwner;
1238
+ /**
1239
+ * `plan/diff.ts`'s `planLoginHost` (#267): adds the org's own company
1240
+ * login host (`action.desired.host`) to the ZITADEL instance's trusted
1241
+ * domains, writes zitadel-login's binding, then repoints the apps whose
1242
+ * client id that binding holds. Apps created later are not in it yet:
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.
1304
+ */
1305
+ private zitadelLoginHost;
1063
1306
  /**
1064
1307
  * `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
1065
1308
  * in through a second ZITADEL org, `<slug> customers`, with
@@ -1108,28 +1351,34 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1108
1351
  private ensureLogVolume;
1109
1352
  private coolifyDatabase;
1110
1353
  /**
1111
- * Pushes the database's connection string into the app's environment as
1112
- * `DATABASE_URL` -- never also as `DATABASE_URL_OWNER` (Decision, this
1113
- * campaign's report). Archon's own boot script
1114
- * (`archon/scripts/migrate-and-start.sh`) treats a bare `DATABASE_URL` with
1115
- * no `DATABASE_URL_OWNER` as "before the split": it migrates and serves as
1116
- * the one role it has, which is exactly what a database Coolify just
1117
- * created (one superuser role, no separate runtime role yet) is. Setting
1118
- * both to the *same* superuser URL would instead take the split branch --
1119
- * `db-role-guard.sh` then refuses to boot, since `DATABASE_URL_OWNER` being
1120
- * set means it expects `DATABASE_URL` to connect as a distinct,
1121
- * non-superuser runtime role (`<database>_rt`), which nothing here creates.
1122
- * Minting that role's own password and URL is a provisioning step of its
1123
- * own, not built here (Finding, this campaign's report).
1354
+ * Pushes the database's connection string into the app's environment,
1355
+ * split the same way `cmsSite`'s own database is (#117, #239): the
1356
+ * superuser connection as `DATABASE_URL_OWNER`, and a distinct, restricted
1357
+ * `<dbName>_rt` role's connection as `DATABASE_URL`. Archon's and manage's
1358
+ * own boot scripts (`db-role-guard.sh`) refuse to start against a
1359
+ * superuser `DATABASE_URL`, and lokessmie's refuses to start without
1360
+ * `DATABASE_URL_OWNER` at all -- a bare superuser `DATABASE_URL` with no
1361
+ * `DATABASE_URL_OWNER` is "before the split" there too.
1124
1362
  *
1125
- * The connection string is a live credential
1126
- * (`postgres://user:password@host/db`): it is read from
1127
- * `CoolifyPort.getDatabase` and handed straight to `bulkUpsertEnvs` and
1128
- * nowhere else in this class -- never returned, never recorded in
1129
- * `serviceIds` (hence never in the store or an audit record), never logged,
1130
- * never interpolated into a thrown error.
1363
+ * Both connection strings are live credentials
1364
+ * (`postgres://user:password@host/db`): read from `CoolifyPort.getDatabase`
1365
+ * (or minted here, for the runtime one) and handed straight to
1366
+ * `bulkUpsertEnvs` and nowhere else in this class -- never returned, never
1367
+ * recorded in `serviceIds` (hence never in the store or an audit record),
1368
+ * never logged, never interpolated into a thrown error.
1131
1369
  */
1132
1370
  private pushDatabaseUrl;
1371
+ /**
1372
+ * `null` once the app already has a `DATABASE_URL` -- never rotated, the
1373
+ * same rule `ensureCmsRuntimeUrl` follows and for the same reason: the
1374
+ * app's own boot script creates or updates the live `<dbName>_rt` Postgres
1375
+ * role from exactly the username/password this method mints, once:
1376
+ * overwriting `DATABASE_URL` on a later apply would leave that
1377
+ * already-created role's real password out of sync with whatever foundry
1378
+ * hands the app next. See `ensureCmsRuntimeUrl`'s own doc comment for the
1379
+ * parsing and encoding choices this mirrors exactly.
1380
+ */
1381
+ private ensureAppRuntimeUrl;
1133
1382
  /**
1134
1383
  * Pushes what the org's app needs to sign a user in through ZITADEL (gap
1135
1384
  * P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
@@ -1146,8 +1395,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1146
1395
  * (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
1147
1396
  * reason. `AUTH_APP_KEY` (#206 -- which broker an org's app registers with
1148
1397
  * is its own design question, left out of #204's own "How" rather than
1149
- * guessed) and DATABASE_URL_OWNER (a runtime-role split not built yet,
1150
- * `pushDatabaseUrl`'s own doc comment) are out of scope here (task brief).
1398
+ * guessed) is out of scope here (task brief). `DATABASE_URL_OWNER`/
1399
+ * `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
1400
+ * that call runs only once `hosting.database` is declared and
1401
+ * provisioned, where this one is unconditional (#239).
1151
1402
  */
1152
1403
  private pushAppEnv;
1153
1404
  /**
@@ -1523,6 +1774,89 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1523
1774
  /** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
1524
1775
  private deliverWithRetry;
1525
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
+ * `previousTunnelId` (round 4 finding 1): the old domain's CNAMEs were
1797
+ * published under whatever tunnel id was live the *last* time this step
1798
+ * recorded that domain, which is `previousTunnelId`, not necessarily
1799
+ * `tunnelId` -- a tunnel recreated under the same name
1800
+ * (`findTunnelByName` returning a new id) in the same apply that also
1801
+ * changes the domain leaves the old domain's records pointed at the old
1802
+ * id. Matching only `tunnelId`'s target left them live forever. Both
1803
+ * targets are checked so this stays a no-op once a retry has already
1804
+ * moved the old domain onto the new id.
1805
+ */
1806
+ private deleteStaleTunnelDnsRecords;
1807
+ /**
1808
+ * #236: upserts a proxied CNAME to this tunnel for each of
1809
+ * `tunnelIngressHostnames(org)` that actually belongs to `domain`'s own
1810
+ * zone -- a hosting app still served off the shared `*.oikos.host`
1811
+ * wildcard tunnel (`scripts/shared-ingress.mjs`) can appear in that list
1812
+ * alongside `agora.<domain>` (#164's unconditional Agora hostname) when
1813
+ * `hosting.usesDomain` is off, and `<slug>.oikos.host` is not this org's
1814
+ * zone to touch.
1815
+ *
1816
+ * Checks every hostname for a conflicting record *before* writing any
1817
+ * (round 2 finding: a conflict found only partway through a hostname-by-
1818
+ * hostname pass left earlier hostnames written but the domain never
1819
+ * recorded -- an orphaned partial write). An existing A or AAAA record,
1820
+ * or a CNAME already pointed anywhere but this tunnel or (round 3) its own
1821
+ * immediately prior tunnel id, blocks the whole step with a named
1822
+ * conflict rather than being adopted or replaced, the same do-not-adopt
1823
+ * policy {@link cloudflareZone} itself follows for the zone. A CNAME
1824
+ * already pointed at this tunnel is left alone, so a second apply changes
1825
+ * nothing. The caller ({@link cloudflareTunnel}) records the domain and the
1826
+ * returned hostname list as scope ids only once this method -- and, on a
1827
+ * domain change, the old domain's own cleanup -- has returned successfully
1828
+ * (round 4 finding 3), so a crash mid-write is recovered by re-running
1829
+ * this same idempotent step, never silently skipped.
1830
+ *
1831
+ * Round 3: `previousTunnelId` is the tunnel id this same step recorded
1832
+ * last time it ran (`cloudflareTunnel`'s own `cloudflare:tunnel:<key>`
1833
+ * resource id, read before `tunnel` above is computed for this run) -- a
1834
+ * tunnel Cloudflare shows as recreated under the same name
1835
+ * (`findTunnelByName` returning a new id for `<slug>-tunnel`) leaves every
1836
+ * CNAME still pointed at the *old* id. That is this org's own stale
1837
+ * record, not a foreign one, so it is repointed rather than refused.
1838
+ *
1839
+ * Round 4 finding 2: `previousHostnames` is this domain's own previously
1840
+ * recorded hostname list (empty when the domain just changed --
1841
+ * {@link cloudflareTunnel} hands the *whole* old domain to
1842
+ * {@link deleteStaleTunnelDnsRecords} for that case instead). A name in it
1843
+ * that the org's current config no longer produces is dropped from
1844
+ * `hostnames` below but was never cleaned up before this round: its CNAME
1845
+ * stayed live and its name stayed in the recorded list forever, because
1846
+ * only a domain *change* ever ran cleanup. Each dropped name's CNAME is
1847
+ * deleted here when it still points at this tunnel or its immediately
1848
+ * prior id; a name already gone, or now pointed at something foreign, is
1849
+ * simply left out of the returned list -- nothing here is this method's to
1850
+ * reclaim or report as a conflict.
1851
+ *
1852
+ * Round 5 finding 1: `context` is used only to record
1853
+ * {@link CLOUDFLARE_TUNNEL_DNS_PENDING_DOMAIN_SCOPE_KEY}/
1854
+ * {@link CLOUDFLARE_TUNNEL_DNS_PENDING_HOSTNAMES_SCOPE_KEY} after each
1855
+ * individual CNAME write below succeeds, so a crash or a thrown conflict
1856
+ * partway through never leaves an already-created CNAME out of every
1857
+ * recorded list -- `destroy` checks this pending pair too.
1858
+ */
1859
+ private publishTunnelDnsRecords;
1526
1860
  /**
1527
1861
  * `plan/diff.ts`'s `planZone`: finds or creates the Cloudflare zone for the
1528
1862
  * org's own domain. Refuses to adopt a zone the store already records