@wtfalch/foundry 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/declare.d.ts +3 -1
- package/dist/bootstrap/declare.js +6 -2
- package/dist/bootstrap/versions.d.ts +17 -0
- package/dist/bootstrap/versions.js +12 -0
- package/dist/calls-sfu.d.ts +5 -4
- package/dist/calls-sfu.js +6 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- 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/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 +2 -2
- package/dist/plan/calls-matrix.js +3 -2
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +5 -3
- package/dist/plan/diff.d.ts +15 -2
- package/dist/plan/diff.js +306 -41
- package/dist/plan/executor.d.ts +73 -28
- package/dist/plan/executor.js +217 -55
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/login-host.d.ts +64 -0
- package/dist/plan/login-host.js +52 -0
- package/dist/plan/plan-apply.d.ts +21 -8
- package/dist/plan/plan-apply.js +10 -9
- package/dist/plan/platform-diff.d.ts +23 -1
- package/dist/plan/platform-diff.js +35 -0
- package/dist/plan/platform-providers.d.ts +1 -1
- package/dist/plan/platform-providers.js +71 -16
- package/dist/plan/providers-readers.js +44 -1
- package/dist/store/audit.d.ts +40 -4
- package/dist/store/audit.js +114 -29
- package/dist/store/postgres.js +4 -1
- package/dist/store/schema.d.ts +37 -1
- package/dist/store/schema.js +7 -0
- package/package.json +5 -3
- package/versions.json +8 -0
package/dist/plan/executor.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { URL } from 'node:url';
|
|
3
3
|
import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
|
|
4
|
-
import { AGORA_CLIENT_ID_SCOPE_KEY, agoraHostnameFor, desiredAgoraEnv, } from './agora.js';
|
|
4
|
+
import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, agoraHostnameFor, desiredAgoraEnv, } from './agora.js';
|
|
5
5
|
import { MAS_IMAGE, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
6
|
+
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
7
|
+
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, } from './login-host.js';
|
|
6
8
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
7
|
-
import { ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, } from './platform-diff.js';
|
|
9
|
+
import { APP_ENV_APPLIED_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, resolveAppEnv, } from './platform-diff.js';
|
|
8
10
|
// ---------------------------------------------------------------------------
|
|
9
11
|
// Errors
|
|
10
12
|
// ---------------------------------------------------------------------------
|
|
@@ -228,27 +230,6 @@ const AGORA_APP_DOCKERFILE = 'apps/agora/Dockerfile';
|
|
|
228
230
|
* restated rather than shared across the two files.
|
|
229
231
|
*/
|
|
230
232
|
const LOG_DIR = '/app/logs';
|
|
231
|
-
/**
|
|
232
|
-
* The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
|
|
233
|
-
* not a `system:kind:key` resource id (that slot already holds the app's
|
|
234
|
-
* `appId`, `zitadelApp`'s own `resourceId`) and not a secret (`deliverSecret`
|
|
235
|
-
* is for that), so a flat scope id like `zitadel_org`/`zitadel_project`
|
|
236
|
-
* above. A level-1 org declares exactly one web `issuerApps` entry for its
|
|
237
|
-
* one standard app, so one flat key is enough; an org with more than one web
|
|
238
|
-
* app would need a richer key, not built here (Finding, this campaign's
|
|
239
|
-
* report).
|
|
240
|
-
*/
|
|
241
|
-
const APP_CLIENT_ID_SCOPE_KEY = 'zitadel_app_client_id';
|
|
242
|
-
/**
|
|
243
|
-
* #139: the `cms` app's own ZITADEL web client's `clientId`, recorded
|
|
244
|
-
* separately from {@link APP_CLIENT_ID_SCOPE_KEY} -- that key belongs to the
|
|
245
|
-
* org's own hosted app (`diff.ts`'s `planIssuerApps`), a different
|
|
246
|
-
* `zitadel:app` step from the fixed `cms` client `diff.ts`'s `planCmsClient`
|
|
247
|
-
* plans. `cmsSite` reads this key to push `AUTH_CLIENT_ID` onto the cms app,
|
|
248
|
-
* the same way `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` for the hosted
|
|
249
|
-
* app.
|
|
250
|
-
*/
|
|
251
|
-
const CMS_CLIENT_ID_SCOPE_KEY = 'cms_client_id';
|
|
252
233
|
/**
|
|
253
234
|
* `cmsSite` provisions two applications from one `cms:site` step, but
|
|
254
235
|
* `execute`'s own return value auto-records only one `resourceId`, under
|
|
@@ -290,6 +271,13 @@ const CMS_DATABASE_SCOPE_KEY = 'coolify_cms_database';
|
|
|
290
271
|
* `resend_mail_domain`.
|
|
291
272
|
*/
|
|
292
273
|
const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
|
|
274
|
+
/**
|
|
275
|
+
* #235: the Coolify app running `cloudflared` for this org's own tunnel --
|
|
276
|
+
* one connector per org tunnel (Decided, William, 2026-10-01), the same
|
|
277
|
+
* `ensureCloudflaredConnector` every platform tunnel step already calls
|
|
278
|
+
* (`platform-providers.ts`), never a shared tunnel with appended ingress.
|
|
279
|
+
*/
|
|
280
|
+
const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
|
|
293
281
|
/**
|
|
294
282
|
* #162: the owner's own mailbox, created once inside `coolify:mail` after
|
|
295
283
|
* `applyOrgMail` succeeds, alongside {@link MAIL_DOMAIN_SCOPE_KEY} -- another
|
|
@@ -434,6 +422,8 @@ export class FoundryStepExecutor {
|
|
|
434
422
|
return this.zitadelIssuer(action, org, context);
|
|
435
423
|
case 'zitadel:owner':
|
|
436
424
|
return this.zitadelOwner(action, org, context);
|
|
425
|
+
case 'zitadel:login-host':
|
|
426
|
+
return this.zitadelLoginHost(action, org, context);
|
|
437
427
|
case 'zitadel:app':
|
|
438
428
|
return this.zitadelApp(action, org, context);
|
|
439
429
|
case 'zitadel:customers':
|
|
@@ -603,6 +593,54 @@ export class FoundryStepExecutor {
|
|
|
603
593
|
});
|
|
604
594
|
return { resourceId: userId };
|
|
605
595
|
}
|
|
596
|
+
/**
|
|
597
|
+
* `plan/diff.ts`'s `planLoginHost` (#267): adds the org's own company
|
|
598
|
+
* login host (`action.desired.host`) to the ZITADEL instance's trusted
|
|
599
|
+
* domains, writes zitadel-login's binding, then repoints the apps whose
|
|
600
|
+
* client id that binding holds. Apps created later are not in it yet:
|
|
601
|
+
* the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
|
|
602
|
+
*/
|
|
603
|
+
async zitadelLoginHost(action, org, context) {
|
|
604
|
+
const recorded = context.recordedIds();
|
|
605
|
+
const zitadelOrgId = recorded.zitadel_org;
|
|
606
|
+
if (!zitadelOrgId) {
|
|
607
|
+
throw new Error(`zitadel:login-host for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
608
|
+
}
|
|
609
|
+
// `planLoginHost` blocks the step while this port is not wired.
|
|
610
|
+
const bindings = this.ports.loginHostBindings;
|
|
611
|
+
if (!bindings) {
|
|
612
|
+
throw new Error(`zitadel:login-host for ${org.slug}: the loginHostBindings port is not wired`);
|
|
613
|
+
}
|
|
614
|
+
const host = action.desired.host;
|
|
615
|
+
await this.ports.zitadel.ensureTrustedDomain(host);
|
|
616
|
+
// zitadel-login's own routing table, written before any app is repointed
|
|
617
|
+
// so no app ever points at a host with no binding.
|
|
618
|
+
const clientIds = loginHostClientIds(recorded);
|
|
619
|
+
await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
|
|
620
|
+
// Only apps whose client id the binding now holds: any other app in the
|
|
621
|
+
// project would lose sign-in. The rest move on a later run, once the
|
|
622
|
+
// recorded client ids differ from the binding and replan this step.
|
|
623
|
+
const boundAppIds = new Set([
|
|
624
|
+
...(recorded[APP_CLIENT_ID_SCOPE_KEY]
|
|
625
|
+
? org.hosting.issuerApps.filter((a) => a.kind === 'web').map((a) => a.name)
|
|
626
|
+
: []),
|
|
627
|
+
...(recorded[CMS_CLIENT_ID_SCOPE_KEY] ? ['cms'] : []),
|
|
628
|
+
...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
|
|
629
|
+
].map((name) => recorded[`zitadel:app:${name}`]));
|
|
630
|
+
const projectId = recorded.zitadel_project;
|
|
631
|
+
if (projectId) {
|
|
632
|
+
const apps = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
|
|
633
|
+
for (const app of apps) {
|
|
634
|
+
if (app.state === 'APP_STATE_REMOVED' || !boundAppIds.has(app.id))
|
|
635
|
+
continue;
|
|
636
|
+
await this.ports.zitadel.updateOidcAppLoginBaseUri(zitadelOrgId, projectId, app.id, `https://${host}/`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
// Last, so a crash above reruns the whole step. A client id recorded
|
|
640
|
+
// after this point differs from it and replans the step next run.
|
|
641
|
+
context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
|
|
642
|
+
return { resourceId: host };
|
|
643
|
+
}
|
|
606
644
|
/**
|
|
607
645
|
* `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
|
|
608
646
|
* in through a second ZITADEL org, `<slug> customers`, with
|
|
@@ -827,11 +865,17 @@ export class FoundryStepExecutor {
|
|
|
827
865
|
// resource id is already recorded by the time this runs -- but this also
|
|
828
866
|
// covers the database being turned on after the app already exists (the
|
|
829
867
|
// app step still ran this apply, found the app by name, and lands here).
|
|
868
|
+
let dbPush = { pushed: false, changed: false };
|
|
830
869
|
if (org.hosting.database) {
|
|
831
870
|
const dbId = context.recordedIds()[`coolify:database:${action.key}`];
|
|
832
|
-
if (dbId)
|
|
833
|
-
await this.pushDatabaseUrl(appId, dbId);
|
|
871
|
+
if (dbId) {
|
|
872
|
+
dbPush = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
873
|
+
}
|
|
834
874
|
}
|
|
875
|
+
// Restart when the push changed something, or when no earlier run got as
|
|
876
|
+
// far as restarting after one (marker missing): a crash between the push
|
|
877
|
+
// and the restart leaves the env right and the container stale.
|
|
878
|
+
const dbNeedsRestart = dbPush.pushed && (dbPush.changed || !context.recordedIds()[DB_ENV_APPLIED_SCOPE_KEY]);
|
|
835
879
|
// Every app keeps its own logs across a redeploy (#75). Unconditional,
|
|
836
880
|
// like the env push below: found by mount path before create, so a
|
|
837
881
|
// re-run (or the drift step `diff.ts`'s `planApp` plans for an app that
|
|
@@ -843,7 +887,17 @@ export class FoundryStepExecutor {
|
|
|
843
887
|
// The app receives whatever it needs to sign in (gap P2). Unconditional,
|
|
844
888
|
// unlike the database push above: every app has a hostname, and an
|
|
845
889
|
// upsert of the same value on a later apply is a no-op, not a change.
|
|
846
|
-
await this.pushAppEnv(appId, org, context);
|
|
890
|
+
const appEnv = await this.pushAppEnv(appId, org, context);
|
|
891
|
+
// Rule 4 (#239, #243): a container reads its env only at start, so an app
|
|
892
|
+
// that was already there is restarted, once, when its database URLs or its
|
|
893
|
+
// declared appEnv changed.
|
|
894
|
+
if ((appEnv.changed || dbNeedsRestart) && existing)
|
|
895
|
+
await this.ports.coolify.restart(appId);
|
|
896
|
+
// Recorded only after the restart succeeded: a failed restart leaves the
|
|
897
|
+
// old value, so the next plan replans (rule 3). The db marker is not a URL.
|
|
898
|
+
context.recordScopeId(APP_ENV_APPLIED_SCOPE_KEY, appEnv.applied);
|
|
899
|
+
if (dbPush.pushed)
|
|
900
|
+
context.recordScopeId(DB_ENV_APPLIED_SCOPE_KEY, '1');
|
|
847
901
|
// #140 rework (2026-09-28 lettermint-org-mail swap): the hosted app's own
|
|
848
902
|
// Lettermint project, domain restriction and `MAIL_FROM` (only the app
|
|
849
903
|
// started by this same step, per the design's "only apps that send mail
|
|
@@ -927,39 +981,80 @@ export class FoundryStepExecutor {
|
|
|
927
981
|
// (`coolifyApp`'s own push handles the more common order, database
|
|
928
982
|
// before app on the same apply -- `diff.ts`'s `computePlanDetail`).
|
|
929
983
|
const appId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
930
|
-
if (appId)
|
|
931
|
-
await this.pushDatabaseUrl(appId, dbId);
|
|
984
|
+
if (appId) {
|
|
985
|
+
const push = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
986
|
+
if (push.pushed) {
|
|
987
|
+
if (push.changed || !context.recordedIds()[DB_ENV_APPLIED_SCOPE_KEY]) {
|
|
988
|
+
await this.ports.coolify.restart(appId);
|
|
989
|
+
}
|
|
990
|
+
context.recordScopeId(DB_ENV_APPLIED_SCOPE_KEY, '1');
|
|
991
|
+
}
|
|
992
|
+
}
|
|
932
993
|
return { resourceId: dbId };
|
|
933
994
|
}
|
|
934
995
|
/**
|
|
935
|
-
* Pushes the database's connection string into the app's environment
|
|
936
|
-
*
|
|
937
|
-
*
|
|
938
|
-
*
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
* `db-role-guard.sh` then refuses to boot, since `DATABASE_URL_OWNER` being
|
|
944
|
-
* set means it expects `DATABASE_URL` to connect as a distinct,
|
|
945
|
-
* non-superuser runtime role (`<database>_rt`), which nothing here creates.
|
|
946
|
-
* Minting that role's own password and URL is a provisioning step of its
|
|
947
|
-
* own, not built here (Finding, this campaign's report).
|
|
996
|
+
* Pushes the database's connection string into the app's environment,
|
|
997
|
+
* split the same way `cmsSite`'s own database is (#117, #239): the
|
|
998
|
+
* superuser connection as `DATABASE_URL_OWNER`, and a distinct, restricted
|
|
999
|
+
* `<dbName>_rt` role's connection as `DATABASE_URL`. Archon's and manage's
|
|
1000
|
+
* own boot scripts (`db-role-guard.sh`) refuse to start against a
|
|
1001
|
+
* superuser `DATABASE_URL`, and lokessmie's refuses to start without
|
|
1002
|
+
* `DATABASE_URL_OWNER` at all -- a bare superuser `DATABASE_URL` with no
|
|
1003
|
+
* `DATABASE_URL_OWNER` is "before the split" there too.
|
|
948
1004
|
*
|
|
949
|
-
*
|
|
950
|
-
* (`postgres://user:password@host/db`):
|
|
951
|
-
*
|
|
952
|
-
* nowhere else in this class -- never returned, never
|
|
953
|
-
* `serviceIds` (hence never in the store or an audit record),
|
|
954
|
-
* never interpolated into a thrown error.
|
|
1005
|
+
* Both connection strings are live credentials
|
|
1006
|
+
* (`postgres://user:password@host/db`): read from `CoolifyPort.getDatabase`
|
|
1007
|
+
* (or minted here, for the runtime one) and handed straight to
|
|
1008
|
+
* `bulkUpsertEnvs` and nowhere else in this class -- never returned, never
|
|
1009
|
+
* recorded in `serviceIds` (hence never in the store or an audit record),
|
|
1010
|
+
* never logged, never interpolated into a thrown error.
|
|
955
1011
|
*/
|
|
956
|
-
async pushDatabaseUrl(appId, dbId) {
|
|
1012
|
+
async pushDatabaseUrl(appId, dbId, dbName) {
|
|
957
1013
|
const db = await this.ports.coolify.getDatabase(dbId);
|
|
1014
|
+
// Nothing to push; `planApp` reports this as blocked from the live reader.
|
|
958
1015
|
if (!db.internal_db_url)
|
|
959
|
-
return;
|
|
960
|
-
|
|
961
|
-
{ key: '
|
|
962
|
-
]
|
|
1016
|
+
return { pushed: false, changed: false };
|
|
1017
|
+
const envs = [
|
|
1018
|
+
{ key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
|
|
1019
|
+
];
|
|
1020
|
+
const runtimeUrl = await this.ensureAppRuntimeUrl(appId, db.internal_db_url, dbName);
|
|
1021
|
+
if (runtimeUrl)
|
|
1022
|
+
envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
|
|
1023
|
+
// True when a value actually changed, so the caller restarts the app only
|
|
1024
|
+
// then (the owner URL is upserted on every apply).
|
|
1025
|
+
const ownerBefore = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL_OWNER' && !e.is_preview)?.value;
|
|
1026
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
1027
|
+
return { pushed: true, changed: runtimeUrl !== null || ownerBefore !== db.internal_db_url };
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* `null` once the app already has a `DATABASE_URL` -- never rotated, the
|
|
1031
|
+
* same rule `ensureCmsRuntimeUrl` follows and for the same reason: the
|
|
1032
|
+
* app's own boot script creates or updates the live `<dbName>_rt` Postgres
|
|
1033
|
+
* role from exactly the username/password this method mints, once:
|
|
1034
|
+
* overwriting `DATABASE_URL` on a later apply would leave that
|
|
1035
|
+
* already-created role's real password out of sync with whatever foundry
|
|
1036
|
+
* hands the app next. See `ensureCmsRuntimeUrl`'s own doc comment for the
|
|
1037
|
+
* parsing and encoding choices this mirrors exactly.
|
|
1038
|
+
*/
|
|
1039
|
+
async ensureAppRuntimeUrl(appId, dbInternalUrl, dbName) {
|
|
1040
|
+
const envs = await this.ports.coolify.listEnvs(appId);
|
|
1041
|
+
// An existing `DATABASE_URL` that is still the superuser string is what an
|
|
1042
|
+
// app got before the split (#239): treat it as absent so the runtime role
|
|
1043
|
+
// is minted and replaces it, or the app's db-role-guard refuses to boot.
|
|
1044
|
+
const already = envs.some((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value !== dbInternalUrl);
|
|
1045
|
+
if (already)
|
|
1046
|
+
return null;
|
|
1047
|
+
let host;
|
|
1048
|
+
let pathname;
|
|
1049
|
+
let search;
|
|
1050
|
+
try {
|
|
1051
|
+
({ host, pathname, search } = new URL(dbInternalUrl));
|
|
1052
|
+
}
|
|
1053
|
+
catch {
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
const password = randomBytes(24).toString('base64url');
|
|
1057
|
+
return `postgres://${dbName}_rt:${password}@${host}${pathname}${search}`;
|
|
963
1058
|
}
|
|
964
1059
|
/**
|
|
965
1060
|
* Pushes what the org's app needs to sign a user in through ZITADEL (gap
|
|
@@ -977,8 +1072,10 @@ export class FoundryStepExecutor {
|
|
|
977
1072
|
* (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
|
|
978
1073
|
* reason. `AUTH_APP_KEY` (#206 -- which broker an org's app registers with
|
|
979
1074
|
* is its own design question, left out of #204's own "How" rather than
|
|
980
|
-
* guessed)
|
|
981
|
-
* `
|
|
1075
|
+
* guessed) is out of scope here (task brief). `DATABASE_URL_OWNER`/
|
|
1076
|
+
* `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
|
|
1077
|
+
* that call runs only once `hosting.database` is declared and
|
|
1078
|
+
* provisioned, where this one is unconditional (#239).
|
|
982
1079
|
*/
|
|
983
1080
|
async pushAppEnv(appId, org, context) {
|
|
984
1081
|
const recorded = context.recordedIds();
|
|
@@ -1009,7 +1106,21 @@ export class FoundryStepExecutor {
|
|
|
1009
1106
|
const cookieSecret = await this.ensureAuthCookieSecret(appId);
|
|
1010
1107
|
if (cookieSecret)
|
|
1011
1108
|
envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
|
|
1109
|
+
// #243: the app's own declared map from its env var names to values the
|
|
1110
|
+
// plan already mints for this org (Files, AI, mail) -- each bespoke app
|
|
1111
|
+
// names these differently (lokessmie uses STORAGE_*/AI_*). Nothing is
|
|
1112
|
+
// minted here; a source with no value yet (its own step has not run,
|
|
1113
|
+
// or has not run for this apply) is simply omitted, the same way every
|
|
1114
|
+
// other optional entry above is.
|
|
1115
|
+
const appEnv = resolveAppEnv(org, recorded).values;
|
|
1116
|
+
for (const [key, value] of Object.entries(appEnv))
|
|
1117
|
+
envs.push({ key, value });
|
|
1012
1118
|
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
1119
|
+
// `coolifyApp` records `applied` once the restart succeeded, so `planApp`
|
|
1120
|
+
// replans this step when the declared map changes (rule 3). Ids and a
|
|
1121
|
+
// domain, never a secret.
|
|
1122
|
+
const applied = JSON.stringify(appEnv);
|
|
1123
|
+
return { changed: applied !== (recorded[APP_ENV_APPLIED_SCOPE_KEY] ?? '{}'), applied };
|
|
1013
1124
|
}
|
|
1014
1125
|
/**
|
|
1015
1126
|
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
@@ -2081,6 +2192,19 @@ export class FoundryStepExecutor {
|
|
|
2081
2192
|
await this.ports.cloudflare.putTunnelConfig(tunnelId, {
|
|
2082
2193
|
ingress: [{ service: 'http_status:404' }],
|
|
2083
2194
|
});
|
|
2195
|
+
// #235: park stops the connector too -- an ingress with nothing
|
|
2196
|
+
// routed is still a running container dialed into the tunnel.
|
|
2197
|
+
const connectorAppId = context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
|
|
2198
|
+
if (connectorAppId) {
|
|
2199
|
+
try {
|
|
2200
|
+
await this.ports.coolify.raw('POST', `/applications/${connectorAppId}/stop`);
|
|
2201
|
+
}
|
|
2202
|
+
catch (error) {
|
|
2203
|
+
// Already gone (destroyed out of band): nothing left to stop.
|
|
2204
|
+
if (!isNotFoundError(error))
|
|
2205
|
+
throw error;
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2084
2208
|
return { resourceId: tunnelId };
|
|
2085
2209
|
}
|
|
2086
2210
|
const tunnelName = `${org.slug}-tunnel`;
|
|
@@ -2110,6 +2234,29 @@ export class FoundryStepExecutor {
|
|
|
2110
2234
|
await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
|
|
2111
2235
|
ingress: [...ingress, { service: 'http_status:404' }],
|
|
2112
2236
|
});
|
|
2237
|
+
// #235: a connector for this org's own tunnel, the same call every
|
|
2238
|
+
// platform tunnel step already makes (Decided, William, 2026-10-01:
|
|
2239
|
+
// one connector per org tunnel, never a shared tunnel with appended
|
|
2240
|
+
// ingress). Without this, the tunnel exists and is configured but
|
|
2241
|
+
// nothing is dialed into it, so `hostname` answers with a Cloudflare
|
|
2242
|
+
// error -- #197's own gap, for an org's tunnel rather than a platform
|
|
2243
|
+
// one.
|
|
2244
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2245
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2246
|
+
await ensureCloudflaredConnector({
|
|
2247
|
+
coolify: this.ports.coolify,
|
|
2248
|
+
cloudflare: this.ports.cloudflare,
|
|
2249
|
+
tunnelId: tunnel.id,
|
|
2250
|
+
appName: `${org.slug}-cloudflared`,
|
|
2251
|
+
projectUuid,
|
|
2252
|
+
environment,
|
|
2253
|
+
resolveServer: () => this.resolveServer(),
|
|
2254
|
+
recordedConnectorAppId: context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
2255
|
+
recordAppId: async (appId) => {
|
|
2256
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, appId);
|
|
2257
|
+
await context.persistScopeIds();
|
|
2258
|
+
},
|
|
2259
|
+
});
|
|
2113
2260
|
return { resourceId: tunnel.id };
|
|
2114
2261
|
}
|
|
2115
2262
|
/**
|
|
@@ -2507,9 +2654,24 @@ export class FoundryStepExecutor {
|
|
|
2507
2654
|
const { org, recordedIds } = context;
|
|
2508
2655
|
try {
|
|
2509
2656
|
switch (`${target.system}:${target.kind}`) {
|
|
2510
|
-
case 'cloudflare:tunnel':
|
|
2657
|
+
case 'cloudflare:tunnel': {
|
|
2658
|
+
// #235: the connector app first, its own 404 handled locally --
|
|
2659
|
+
// a retry where this already succeeded must still reach
|
|
2660
|
+
// `deleteTunnel` below, which the outer catch's own 404 handling
|
|
2661
|
+
// would otherwise short-circuit before it ran.
|
|
2662
|
+
const connectorAppId = recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
|
|
2663
|
+
if (connectorAppId) {
|
|
2664
|
+
try {
|
|
2665
|
+
await this.ports.coolify.deleteApplication(connectorAppId);
|
|
2666
|
+
}
|
|
2667
|
+
catch (error) {
|
|
2668
|
+
if (!isNotFoundError(error))
|
|
2669
|
+
throw error;
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2511
2672
|
await this.ports.cloudflare.deleteTunnel(target.id);
|
|
2512
2673
|
return;
|
|
2674
|
+
}
|
|
2513
2675
|
case 'cloudflare:access':
|
|
2514
2676
|
await this.ports.cloudflare.deleteAccessApp(target.id);
|
|
2515
2677
|
return;
|
package/dist/plan/index.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCop
|
|
|
24
24
|
export type { ScopeResolver, RecordedIds } from './providers-readers.js';
|
|
25
25
|
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
|
|
26
26
|
export type { AgoraHostPort, AgoraHostValues } from './agora.js';
|
|
27
|
+
export type { LoginHostBinding, LoginHostBindingsPort } from './login-host.js';
|
|
28
|
+
export { loginHostnameFor } from './login-host.js';
|
|
27
29
|
export type { MailIdentityApp, MailIdentityAudienceCheck } from './mail-identity.js';
|
|
28
30
|
export { checkMailIdentityAudience } from './mail-identity.js';
|
|
29
31
|
export type { MailAllowlistApp } from './mail-identity-allowlist.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export { createZitadelAdminMinter } from './zitadel-machine-user.js';
|
|
|
12
12
|
export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
13
13
|
export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
14
14
|
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
|
|
15
|
+
export { loginHostnameFor } from './login-host.js';
|
|
15
16
|
export { checkMailIdentityAudience } from './mail-identity.js';
|
|
16
17
|
export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
|
|
17
18
|
export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { DeclaredOrg } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
|
|
4
|
+
* need to agree on for an org's own company login host -- the hostname and
|
|
5
|
+
* the shape of zitadel-login's own routing entry -- the same split
|
|
6
|
+
* `agora.ts` already makes for Agora's own hostname and env.
|
|
7
|
+
*
|
|
8
|
+
* Decided in https://github.com/wtfalch/auth/issues/137 (William,
|
|
9
|
+
* 2026-10-01): foundry owns company sign-in setup through
|
|
10
|
+
* `@wtfalch/providers`, not `auth`'s own `scripts/namespace-provisioner.mjs`.
|
|
11
|
+
* `auth/docs/company-login-hosts.md` is the enforcement side this hostname
|
|
12
|
+
* and binding feed.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* `auth.<domain>` once the org has its own domain and `issuer.usesDomain` is on, `<slug>-auth.oikos.host`
|
|
16
|
+
* otherwise -- the same per-tier shape `agora.ts`'s own `agoraHostnameFor`
|
|
17
|
+
* uses for Agora, and the two tiers #267's issue names directly
|
|
18
|
+
* ("auth.<domain>" on the org's own domain, "<slug>-auth.oikos.host" for
|
|
19
|
+
* tier 1, riding the shared `*.oikos.host` wildcard tunnel the same way
|
|
20
|
+
* `<slug>-agora.oikos.host` does). `../slug.ts`'s
|
|
21
|
+
* `RESERVED_AUTH_AND_ENVIRONMENTS` already reserves the `auth` *slug* so no
|
|
22
|
+
* org's own `<slug>.oikos.host` can collide with this subdomain, the same
|
|
23
|
+
* way `AGORA_SUFFIX` reserves `-agora`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function loginHostnameFor(org: DeclaredOrg): string;
|
|
26
|
+
/**
|
|
27
|
+
* One entry in zitadel-login's own `LOGIN_HOST_BINDINGS`
|
|
28
|
+
* (`auth/docs/company-login-hosts.md`) -- a single-line JSON document naming
|
|
29
|
+
* every company-bound host, its ZITADEL organization and the client ids
|
|
30
|
+
* allowed to use it. `host` is {@link loginHostnameFor}'s own value;
|
|
31
|
+
* `organizationId` is the org's ZITADEL org id (`executor.ts`'s
|
|
32
|
+
* `zitadelIssuer` recorded it as `zitadel_org`).
|
|
33
|
+
*/
|
|
34
|
+
export interface LoginHostBinding {
|
|
35
|
+
host: string;
|
|
36
|
+
organizationId: string;
|
|
37
|
+
clientIds: string[];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Host-supplied write access to zitadel-login's own `LOGIN_HOST_BINDINGS`
|
|
41
|
+
* env (`auth/docs/company-login-hosts.md`'s own "Set `LOGIN_HOST_BINDINGS`
|
|
42
|
+
* on zitadel-login", "today set by hand"). Optional on
|
|
43
|
+
* {@link FoundryStepExecutorPorts} the same way `jobsScheduler`/`mailCopy`
|
|
44
|
+
* are, but without it the login-host step is blocked, not partly applied:
|
|
45
|
+
* isolation between orgs rests on the binding, and an app repointed at a host
|
|
46
|
+
* with no binding cannot sign in. Wiring the port means the host routes to
|
|
47
|
+
* zitadel-login (DNS and ingress), because the first apply after wiring
|
|
48
|
+
* repoints the org's bound apps.
|
|
49
|
+
*/
|
|
50
|
+
export interface LoginHostBindingsPort {
|
|
51
|
+
upsertBinding(binding: LoginHostBinding): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Scope id holding what the binding was last written with
|
|
55
|
+
* ({@link loginBindingState}). Client ids are not secrets. The planner
|
|
56
|
+
* replans the login-host step when the org's recorded client ids no longer
|
|
57
|
+
* match it, so an app created after the first apply is added to the binding
|
|
58
|
+
* on the next run.
|
|
59
|
+
*/
|
|
60
|
+
export declare const LOGIN_BINDING_SCOPE_KEY = "login_host_binding";
|
|
61
|
+
/** The org's recorded app client ids (hosted app, cms, agora), sorted. */
|
|
62
|
+
export declare function loginHostClientIds(recorded: Readonly<Record<string, string>>): string[];
|
|
63
|
+
/** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
|
|
64
|
+
export declare function loginBindingState(host: string, clientIds: readonly string[]): string;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, } from './agora.js';
|
|
2
|
+
/**
|
|
3
|
+
* #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
|
|
4
|
+
* need to agree on for an org's own company login host -- the hostname and
|
|
5
|
+
* the shape of zitadel-login's own routing entry -- the same split
|
|
6
|
+
* `agora.ts` already makes for Agora's own hostname and env.
|
|
7
|
+
*
|
|
8
|
+
* Decided in https://github.com/wtfalch/auth/issues/137 (William,
|
|
9
|
+
* 2026-10-01): foundry owns company sign-in setup through
|
|
10
|
+
* `@wtfalch/providers`, not `auth`'s own `scripts/namespace-provisioner.mjs`.
|
|
11
|
+
* `auth/docs/company-login-hosts.md` is the enforcement side this hostname
|
|
12
|
+
* and binding feed.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* `auth.<domain>` once the org has its own domain and `issuer.usesDomain` is on, `<slug>-auth.oikos.host`
|
|
16
|
+
* otherwise -- the same per-tier shape `agora.ts`'s own `agoraHostnameFor`
|
|
17
|
+
* uses for Agora, and the two tiers #267's issue names directly
|
|
18
|
+
* ("auth.<domain>" on the org's own domain, "<slug>-auth.oikos.host" for
|
|
19
|
+
* tier 1, riding the shared `*.oikos.host` wildcard tunnel the same way
|
|
20
|
+
* `<slug>-agora.oikos.host` does). `../slug.ts`'s
|
|
21
|
+
* `RESERVED_AUTH_AND_ENVIRONMENTS` already reserves the `auth` *slug* so no
|
|
22
|
+
* org's own `<slug>.oikos.host` can collide with this subdomain, the same
|
|
23
|
+
* way `AGORA_SUFFIX` reserves `-agora`.
|
|
24
|
+
*/
|
|
25
|
+
export function loginHostnameFor(org) {
|
|
26
|
+
// `issuer.usesDomain` is the issuer's opt-in into the org's domain
|
|
27
|
+
// (`org.ts`'s `IssuerConfig`): a domain alone never moves it, so a domain
|
|
28
|
+
// with `usesDomain` false stays on the tier 1 host.
|
|
29
|
+
return org.domain && org.issuer.usesDomain ? `auth.${org.domain}` : `${org.slug}-auth.oikos.host`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Scope id holding what the binding was last written with
|
|
33
|
+
* ({@link loginBindingState}). Client ids are not secrets. The planner
|
|
34
|
+
* replans the login-host step when the org's recorded client ids no longer
|
|
35
|
+
* match it, so an app created after the first apply is added to the binding
|
|
36
|
+
* on the next run.
|
|
37
|
+
*/
|
|
38
|
+
export const LOGIN_BINDING_SCOPE_KEY = 'login_host_binding';
|
|
39
|
+
/** The org's recorded app client ids (hosted app, cms, agora), sorted. */
|
|
40
|
+
export function loginHostClientIds(recorded) {
|
|
41
|
+
return [
|
|
42
|
+
recorded[APP_CLIENT_ID_SCOPE_KEY],
|
|
43
|
+
recorded[CMS_CLIENT_ID_SCOPE_KEY],
|
|
44
|
+
recorded[AGORA_CLIENT_ID_SCOPE_KEY],
|
|
45
|
+
]
|
|
46
|
+
.filter((id) => Boolean(id))
|
|
47
|
+
.sort();
|
|
48
|
+
}
|
|
49
|
+
/** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
|
|
50
|
+
export function loginBindingState(host, clientIds) {
|
|
51
|
+
return `${host} ${clientIds.join(',')}`;
|
|
52
|
+
}
|
|
@@ -17,14 +17,19 @@ export interface AuditActor {
|
|
|
17
17
|
/**
|
|
18
18
|
* The seam onto `@wtfalch/audit`.
|
|
19
19
|
*
|
|
20
|
-
* #19
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
20
|
+
* #19 asked for `@wtfalch/audit` itself, but at the time that package's
|
|
21
|
+
* `record`/`ledger` were bound to one app's Drizzle table, session model and
|
|
22
|
+
* closed `EventName`/`ActorClass` vocabulary -- none of which foundry's SDK
|
|
23
|
+
* package had. This interface was the stand-in, logged as a Finding on #19
|
|
24
|
+
* rather than a half adoption.
|
|
25
|
+
*
|
|
26
|
+
* #232: `@wtfalch/audit` now supports a service with no tenant database of
|
|
27
|
+
* its own (`ledgerVocabulary`, `tenantId: null`, the org as `target`) -- see
|
|
28
|
+
* `store/audit.ts`'s `createPostgresAuditLog`, which binds `ledger.sign` to
|
|
29
|
+
* foundry's own closed vocabulary and `foundry_audit_events`' replacement,
|
|
30
|
+
* `audit_events` (`@wtfalch/audit`'s own table, through migrations
|
|
31
|
+
* 0022-0026). This interface itself is unchanged -- `bootstrap.ts` and
|
|
32
|
+
* `redactingAudit` need no change either.
|
|
28
33
|
*/
|
|
29
34
|
export interface AuditLog {
|
|
30
35
|
recordPlan(entry: {
|
|
@@ -281,6 +286,13 @@ export interface FoundryPlanApplyOptions {
|
|
|
281
286
|
* step never runs (and fails) where no scheduler is deployed.
|
|
282
287
|
*/
|
|
283
288
|
jobsScheduler?: boolean;
|
|
289
|
+
/**
|
|
290
|
+
* #267: true when the host's executor was built with a `loginHostBindings`
|
|
291
|
+
* port. Optional, and false without it: each org's login host is then
|
|
292
|
+
* reported blocked instead of planned, so nothing is trusted or repointed
|
|
293
|
+
* before zitadel-login's binding can be written.
|
|
294
|
+
*/
|
|
295
|
+
loginHostBindings?: boolean;
|
|
284
296
|
/**
|
|
285
297
|
* #177: true when the host's executor was built with its own `filesOrganisation`
|
|
286
298
|
* / `aiOrganisation` port. Without one, the org step needs the platform's
|
|
@@ -334,6 +346,7 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
334
346
|
private readonly actor;
|
|
335
347
|
private readonly agora;
|
|
336
348
|
private readonly jobsScheduler;
|
|
349
|
+
private readonly loginHostBindings;
|
|
337
350
|
private readonly hostFiles;
|
|
338
351
|
private readonly hostAi;
|
|
339
352
|
private readonly now;
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -78,6 +78,7 @@ export class FoundryPlanApply {
|
|
|
78
78
|
actor;
|
|
79
79
|
agora;
|
|
80
80
|
jobsScheduler;
|
|
81
|
+
loginHostBindings;
|
|
81
82
|
hostFiles;
|
|
82
83
|
hostAi;
|
|
83
84
|
now;
|
|
@@ -92,6 +93,7 @@ export class FoundryPlanApply {
|
|
|
92
93
|
this.actor = options.actor;
|
|
93
94
|
this.agora = options.agora;
|
|
94
95
|
this.jobsScheduler = options.jobsScheduler ?? false;
|
|
96
|
+
this.loginHostBindings = options.loginHostBindings ?? false;
|
|
95
97
|
this.hostFiles = options.filesOrganisation ?? false;
|
|
96
98
|
this.hostAi = options.aiOrganisation ?? false;
|
|
97
99
|
this.now = options.now ?? (() => new Date());
|
|
@@ -351,7 +353,7 @@ export class FoundryPlanApply {
|
|
|
351
353
|
this.orgPlatformReadiness(),
|
|
352
354
|
this.orgAgoraHost(id),
|
|
353
355
|
]);
|
|
354
|
-
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness);
|
|
356
|
+
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, { loginHostBindings: this.loginHostBindings });
|
|
355
357
|
return { org, plan: toPlan(detail), detail };
|
|
356
358
|
}
|
|
357
359
|
/**
|
|
@@ -617,12 +619,13 @@ function stepField(action) {
|
|
|
617
619
|
*
|
|
618
620
|
* Three sources, in this order:
|
|
619
621
|
* - fields no planner step exists for yet, so no apply can make them true:
|
|
620
|
-
* SSO, guests and solo when turned on,
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
622
|
+
* SSO, guests and solo when turned on, an adopted app in the `repo` form
|
|
623
|
+
* (#7's `#15` comment: "declared but refused by `apply`"), and calls or
|
|
624
|
+
* hosting on a domain the org has not recorded (#145: calls on the org's
|
|
625
|
+
* own domain now has a planner step, the same way hosting's own domain
|
|
626
|
+
* does, so it only fails for that one missing-domain reason, not
|
|
627
|
+
* unconditionally). `bespokeAppRepo` is not one of these: `coolify:app`
|
|
628
|
+
* reads it (#240), so it converges with that step like any other field;
|
|
626
629
|
* - chat's reverse re-key, which the planner surfaces but which is not built
|
|
627
630
|
* (#7 amendment 3), so it counts as failed even when no step threw;
|
|
628
631
|
* - on a failed apply, the field of every planned step from the one that
|
|
@@ -646,8 +649,6 @@ function failedFields(org, detail, completed, serviceIds) {
|
|
|
646
649
|
failed.add('guests.enabled');
|
|
647
650
|
if (org.solo.enabled)
|
|
648
651
|
failed.add('solo.enabled');
|
|
649
|
-
if (org.bespokeAppRepo !== null)
|
|
650
|
-
failed.add('bespokeAppRepo');
|
|
651
652
|
if (org.adoptedApp?.form === 'repo')
|
|
652
653
|
failed.add('adoptedApp');
|
|
653
654
|
// `planCallsService`/`planTunnel`/`planZone` all share this same domain
|