@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.
- package/dist/bootstrap/bootstrap.d.ts +12 -8
- package/dist/bootstrap/bootstrap.js +4 -12
- package/dist/bootstrap/declare.d.ts +3 -1
- package/dist/bootstrap/declare.js +6 -2
- package/dist/bootstrap/versions.d.ts +25 -0
- package/dist/bootstrap/versions.js +43 -0
- package/dist/calls-sfu-deploy-support.d.ts +1 -1
- package/dist/calls-sfu-deploy-support.js +1 -1
- package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
- package/dist/calls-sfu-ssh-adapter.js +1 -1
- package/dist/calls-sfu.d.ts +8 -6
- package/dist/calls-sfu.js +9 -6
- package/dist/index.d.ts +3 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0022_chat_enabled.sql +6 -0
- package/dist/migrations/0023_hosting_app_env.sql +8 -0
- package/dist/migrations/0024_audit_events.sql +211 -0
- package/dist/migrations/0025_audit_display.sql +147 -0
- package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
- package/dist/migrations/0027_audit_chain.sql +170 -0
- package/dist/migrations/0028_audit_rls.sql +111 -0
- package/dist/migrations-dir.d.ts +9 -0
- package/dist/migrations-dir.js +10 -0
- package/dist/org.d.ts +24 -0
- package/dist/org.js +13 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +23 -0
- package/dist/plan/calls-matrix.d.ts +4 -2
- package/dist/plan/calls-matrix.js +5 -2
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +5 -3
- package/dist/plan/diff.d.ts +18 -3
- package/dist/plan/diff.js +369 -42
- package/dist/plan/executor.d.ts +378 -44
- package/dist/plan/executor.js +816 -57
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/login-host.d.ts +88 -0
- package/dist/plan/login-host.js +73 -0
- package/dist/plan/plan-apply.d.ts +23 -8
- package/dist/plan/plan-apply.js +30 -10
- package/dist/plan/platform-diff.d.ts +102 -2
- package/dist/plan/platform-diff.js +232 -1
- package/dist/plan/platform-providers.d.ts +37 -2
- package/dist/plan/platform-providers.js +223 -36
- package/dist/plan/providers-readers.js +44 -1
- package/dist/plan.d.ts +43 -0
- package/dist/plan.js +16 -1
- package/dist/store/audit.d.ts +36 -4
- package/dist/store/audit.js +110 -29
- package/dist/store/postgres.d.ts +14 -0
- package/dist/store/postgres.js +17 -9
- package/dist/store/schema.d.ts +37 -1
- package/dist/store/schema.js +7 -0
- package/package.json +10 -5
- package/versions.json +8 -0
- package/dist/migrate.d.ts +0 -38
- package/dist/migrate.js +0 -47
package/dist/plan/executor.d.ts
CHANGED
|
@@ -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
|
|
144
|
-
* (hosting-wiring campaign
|
|
145
|
-
*
|
|
146
|
-
* value is a live credential: it must reach
|
|
147
|
-
* never a step's return value, `serviceIds`
|
|
148
|
-
* record, a log or a thrown error. See
|
|
149
|
-
* `
|
|
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
|
|
346
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1114
|
-
*
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
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
|
-
*
|
|
1126
|
-
* (`postgres://user:password@host/db`):
|
|
1127
|
-
*
|
|
1128
|
-
* nowhere else in this class -- never returned, never
|
|
1129
|
-
* `serviceIds` (hence never in the store or an audit record),
|
|
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)
|
|
1150
|
-
* `
|
|
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
|