@wtfalch/foundry 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -63,15 +63,11 @@ export function declaredCompanyOrg(company, owner) {
63
63
  issuer: { usesDomain: false },
64
64
  site: { enabled: false, usesDomain: false },
65
65
  hosting: {
66
- appEnv: {},
67
66
  enabled: false,
68
67
  usesDomain: false,
69
- database: false,
70
68
  tunnel: false,
71
- gitBranch: DEFAULT_GIT_REF,
72
- buildPack: 'nixpacks',
73
- accessEmails: [],
74
69
  issuerApps: [],
70
+ entries: [],
75
71
  },
76
72
  sso: { enabled: false },
77
73
  guests: { enabled: false },
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, DeclaredOrg, } from './org.js';
2
- export { DEFAULT_MAIL_APPS, validateBuyerIdentity, validateHosting, validateMail, validateOwner, validateSite, } from './org.js';
1
+ export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EnvValue, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, DeclaredOrg, } from './org.js';
2
+ export { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, appEntry, validateBuyerIdentity, validateHosting, validateMail, validateOwner, validateSite, } from './org.js';
3
3
  export type { DeclaredPlatform, PlatformService, PlatformServiceKey } from './platform.js';
4
4
  export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
5
5
  export type { RequestableField, CallsChange, StorageLimitChange, AiLimitChange, DomainChange, MailTierChange, RequestedChangeValue, ChangeClassification, RequestedChangeState, RequestedChange, } from './requested-changes.js';
@@ -30,7 +30,7 @@ export { checkMailAppsAllowlist } from './plan/index.js';
30
30
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
31
31
  export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, } from './plan/index.js';
32
32
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './plan/index.js';
33
- export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, } from './plan/index.js';
33
+ export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './plan/index.js';
34
34
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
35
35
  export type { AiManagementKeyMinter, PlatformAiDeps, PlatformAiRuntime, } from './plan/index.js';
36
36
  export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './plan/index.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { DEFAULT_MAIL_APPS, validateBuyerIdentity, validateHosting, validateMail, validateOwner, validateSite, } from './org.js';
1
+ export { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, appEntry, validateBuyerIdentity, validateHosting, validateMail, validateOwner, validateSite, } from './org.js';
2
2
  export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
3
3
  export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
4
4
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
@@ -13,7 +13,7 @@ export { HttpJobsSchedulerClient } from './plan/index.js';
13
13
  export { checkMailIdentityAudience } from './plan/index.js';
14
14
  export { checkMailAppsAllowlist } from './plan/index.js';
15
15
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
16
- export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, } from './plan/index.js';
16
+ export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './plan/index.js';
17
17
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
18
18
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './plan/index.js';
19
19
  export { connectCallsSfuSsh } from './calls-sfu-ssh-client.js';
@@ -0,0 +1,109 @@
1
+ -- @wtfalch/foundry: an org's hosting as a list of entries, and today's one app
2
+ -- moved onto it as the entry keyed 'app' (org.ts's `HostingConfig.entries`).
3
+ -- Mirrors `src/store/schema.ts`'s `hostingEntries`.
4
+ --
5
+ -- Copies and never drops: hosting_database, hosting_git_branch,
6
+ -- hosting_build_pack, hosting_access_emails, hosting_app_env and
7
+ -- bespoke_app_repo stay for an older reader. Safe to run twice: an org that
8
+ -- already has entries is skipped, and a renamed id key is no longer there to
9
+ -- rename. An org with hosting off but an app id recorded (a parked app) gets
10
+ -- its entry too, so re-enabling it finds its settings.
11
+ --
12
+ -- A row the new validation (org.ts's `validateHostingEntry`) would refuse
13
+ -- makes the migration fail, naming every such org id and column at once, before
14
+ -- anything changes: a row that cannot be saved is worse found weeks later
15
+ -- than as a failed deploy. An empty branch becomes 'main', its default.
16
+
17
+ do $$
18
+ declare
19
+ bad text;
20
+ begin
21
+ select string_agg(format('%s: %s', o.id, c.problem), '; ' order by o.id)
22
+ into bad
23
+ from foundry_declared_orgs o
24
+ cross join lateral (values
25
+ ('bespoke_app_repo ' || quote_literal(o.bespoke_app_repo) || ' is not a repo under wtfalch',
26
+ o.bespoke_app_repo is not null and o.bespoke_app_repo !~ '^wtfalch/[A-Za-z0-9._-]+$'),
27
+ ('hosting_build_pack ' || quote_literal(o.hosting_build_pack) || ' is not available for an entry',
28
+ o.hosting_build_pack not in ('dockerfile', 'nixpacks', 'static')),
29
+ ('hosting_database or hosting_access_emails is set while hosting_enabled is off',
30
+ not o.hosting_enabled and (o.hosting_database or o.hosting_access_emails <> '[]'::jsonb)),
31
+ ('hosting_access_emails holds an invalid email',
32
+ exists (select 1 from jsonb_array_elements_text(o.hosting_access_emails) e
33
+ where e !~ '^[^@[:space:]]+@[^@[:space:]]+$')),
34
+ ('hosting_app_env names a variable foundry sets itself, or an unknown source',
35
+ exists (select 1 from jsonb_each_text(o.hosting_app_env) e
36
+ where e.key = ''
37
+ or e.key ~ '^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$'
38
+ or e.value not in ('filesOrganisationId', 'aiOrganisationId', 'mailDomain')))
39
+ ) as c(problem, refused)
40
+ where c.refused
41
+ -- to_jsonb, because the column does not exist yet on a first run.
42
+ and coalesce(to_jsonb(o) -> 'hosting_entries', '[]'::jsonb) = '[]'::jsonb
43
+ and (o.hosting_enabled or exists (
44
+ select 1 from foundry_applied_state a
45
+ where (a.service_ids -> ('coolify:app:' || a.org_id)) is not null
46
+ and a.org_id = o.id));
47
+ if bad is not null then
48
+ raise exception 'migration 0029: cannot move hosting onto an entry, org %', bad;
49
+ end if;
50
+ end $$;
51
+
52
+ alter table foundry_declared_orgs
53
+ add column if not exists hosting_entries jsonb not null default '[]'::jsonb;
54
+
55
+ update foundry_declared_orgs
56
+ set hosting_entries = jsonb_build_array(jsonb_build_object(
57
+ 'key', 'app',
58
+ 'repo', bespoke_app_repo,
59
+ 'folder', null,
60
+ 'branch', coalesce(nullif(hosting_git_branch, ''), 'main'),
61
+ 'buildPack', hosting_build_pack,
62
+ 'port', null,
63
+ 'healthPath', null,
64
+ 'database', hosting_database,
65
+ 'autoDeploy', true,
66
+ 'accessEmails', hosting_access_emails,
67
+ 'env', coalesce(
68
+ (select jsonb_object_agg(e.key, jsonb_build_object('from', e.value))
69
+ from jsonb_each_text(hosting_app_env) as e),
70
+ '{}'::jsonb)
71
+ ))
72
+ where hosting_entries = '[]'::jsonb
73
+ and (hosting_enabled or exists (
74
+ select 1 from foundry_applied_state a
75
+ where a.org_id = foundry_declared_orgs.id
76
+ and (a.service_ids -> ('coolify:app:' || a.org_id)) is not null));
77
+
78
+ -- The recorded ids and applied-env markers are one key per org today; the
79
+ -- entry key joins them so a second app cannot collide with the first. A key
80
+ -- is renamed only when its new form is absent; if both exist the new one
81
+ -- stays and the old one is left as it is. Without
82
+ -- the rename the planner would not find the app it already made and would
83
+ -- plan it again, restarting it. last_applied_at and
84
+ -- last_applied_declared_org_version are not touched.
85
+ update foundry_applied_state
86
+ set service_ids = service_ids
87
+ - ('coolify:app:' || org_id)
88
+ || jsonb_build_object('coolify:app:' || org_id || ':app', service_ids -> ('coolify:app:' || org_id))
89
+ where (service_ids -> ('coolify:app:' || org_id)) is not null
90
+ and (service_ids -> ('coolify:app:' || org_id || ':app')) is null;
91
+
92
+ update foundry_applied_state
93
+ set service_ids = service_ids
94
+ - ('cloudflare:access:' || org_id)
95
+ || jsonb_build_object('cloudflare:access:' || org_id || ':app', service_ids -> ('cloudflare:access:' || org_id))
96
+ where (service_ids -> ('cloudflare:access:' || org_id)) is not null
97
+ and (service_ids -> ('cloudflare:access:' || org_id || ':app')) is null;
98
+
99
+ update foundry_applied_state
100
+ set service_ids = service_ids - 'app_env_applied'
101
+ || jsonb_build_object('app_env_applied.app', service_ids -> 'app_env_applied')
102
+ where (service_ids -> 'app_env_applied') is not null
103
+ and (service_ids -> 'app_env_applied.app') is null;
104
+
105
+ update foundry_applied_state
106
+ set service_ids = service_ids - 'db_env_applied'
107
+ || jsonb_build_object('db_env_applied.app', service_ids -> 'db_env_applied')
108
+ where (service_ids -> 'db_env_applied') is not null
109
+ and (service_ids -> 'db_env_applied.app') is null;
package/dist/org.d.ts CHANGED
@@ -184,54 +184,85 @@ export interface IssuerApp {
184
184
  */
185
185
  export type HostingBuildPack = 'dockerfile' | 'nixpacks' | 'static' | 'dockercompose';
186
186
  /**
187
- * The org's own hosted app and what runs beside it (#71, part of #14's
187
+ * The org's hosted apps and what runs beside them (#71, part of #14's
188
188
  * adopted/standard/bespoke map) -- distinct from the cms site (`SiteConfig`
189
189
  * above), which is `@wtfalch/cms` content, not the org's own application
190
190
  * code. `usesDomain` follows the same opt-in-per-service rule as mail, chat,
191
191
  * calls, the issuer and the site (#7 amendment 1): setting `DeclaredOrg.domain`
192
192
  * alone never moves the app onto it or starts managing its Cloudflare zone.
193
193
  *
194
- * `database` and `tunnel` are each meaningless without the app itself
195
- * running, so `validateHosting` below requires `enabled` before either can
194
+ * `enabled`, `usesDomain`, `tunnel` and `issuerApps` are the org's own: one
195
+ * park switch, one hostname rule, one tunnel and one ZITADEL project for all
196
+ * its apps. Each app is one of `entries`. `tunnel` is meaningless without the
197
+ * apps running, so `validateHosting` below requires `enabled` before it can
196
198
  * be true.
197
199
  *
198
- * `gitBranch` and `buildPack` were fixed values `executor.ts` hardcoded
199
- * (`'main'`/`'nixpacks'`) until this campaign's hosting-wiring work made them
200
- * declarable; those two strings are their defaults now, kept here so an org
201
- * that never mentions them still gets exactly what shipped before.
200
+ * Until the planner reads more than one entry, only the entry keyed
201
+ * {@link APP_ENTRY_KEY} is planned.
202
202
  */
203
203
  export interface HostingConfig {
204
- /** The org's own app runs on Coolify. */
204
+ /** The org's apps run on Coolify. */
205
205
  enabled: boolean;
206
206
  /** Served on `DeclaredOrg.domain` (and its Cloudflare zone managed) instead of `<slug>.oikos.host` (`../slug.ts`). */
207
207
  usesDomain: boolean;
208
- /** A Postgres database beside the app. Requires `enabled`. */
209
- database: boolean;
210
- /** The app is reached through a Cloudflare tunnel. Requires `enabled`. */
208
+ /** The apps are reached through a Cloudflare tunnel. Requires `enabled`. */
211
209
  tunnel: boolean;
210
+ /** The org's declared ZITADEL OIDC applications (#71). */
211
+ issuerApps: IssuerApp[];
212
+ /** One entry per app the org runs. */
213
+ entries: HostingEntry[];
214
+ }
215
+ /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
216
+ export declare const APP_ENTRY_KEY = "app";
217
+ /**
218
+ * One app the org runs. `database` and `accessEmails` are meaningless
219
+ * without `HostingConfig.enabled`, so `validateHosting` requires it before
220
+ * either is set.
221
+ */
222
+ export interface HostingEntry {
223
+ /** A DNS label, unique per org. It goes into step ids and scope keys, so it never holds `:` or `.`. */
224
+ key: string;
225
+ /** The repo under `wtfalch` the app builds from. `null` is the shared standard app (`executor.ts`'s `DEFAULT_APP_REPO`). */
226
+ repo: string | null;
227
+ /** The app's folder in `repo`. `null` is the repo root. */
228
+ folder: string | null;
212
229
  /** The branch `coolify:app` builds and deploys from. Default: `'main'`. */
213
- gitBranch: string;
230
+ branch: string;
214
231
  /** The Coolify build strategy `coolify:app` provisions with. Default: `'nixpacks'`. */
215
232
  buildPack: HostingBuildPack;
216
- /** The org's declared ZITADEL OIDC applications (#71). */
217
- issuerApps: IssuerApp[];
233
+ /** The port the app listens on. `null` is Coolify's default. */
234
+ port: number | null;
235
+ /** The path that answers 200 when the app is healthy. `null` is none. */
236
+ healthPath: string | null;
237
+ /** A Postgres database beside the app. At most one entry per org. */
238
+ database: boolean;
239
+ /** Deploy on every push to `branch`. Default: `true`. */
240
+ autoDeploy: boolean;
218
241
  /**
219
242
  * Emails Cloudflare Access lets through to the app's hostname (#84). Empty
220
- * means no Access gate. Requires `enabled`.
243
+ * means no Access gate. Only the entry keyed {@link APP_ENTRY_KEY} may set it.
221
244
  */
222
245
  accessEmails: string[];
223
246
  /**
224
- * Maps the app's own env var name to one of `AppEnvSource`'s values --
225
- * each a value the plan already mints for this org (Files, AI, mail) but
226
- * never used to push an app's own env (#243). Each bespoke app names these
227
- * differently (lokessmie uses `STORAGE_*`/`AI_*`); this is how an org
228
- * declares its own app's names for them. `{}` means the app names none of
229
- * them, so `pushAppEnv` pushes nothing from this map.
247
+ * The app's own env var names and what they hold (#243). Each bespoke app
248
+ * names the values the plan mints (Files, AI, mail) differently (lokessmie
249
+ * uses `STORAGE_*`/`AI_*`); `{ from }` declares its own name for one. `{}`
250
+ * means the app names none of them, so `pushAppEnv` pushes nothing from
251
+ * this map.
230
252
  */
231
- appEnv: Record<string, AppEnvSource>;
253
+ env: Record<string, EnvValue>;
232
254
  }
233
255
  /**
234
- * The closed set of values the plan already mints that `HostingConfig.appEnv`
256
+ * A plain string is pushed as it is, so `validateHosting` refuses one that
257
+ * looks like a secret. `{ from }` names a value the plan already mints.
258
+ */
259
+ export type EnvValue = string | {
260
+ from: AppEnvSource;
261
+ };
262
+ /** The org's `app` entry: today's one app. */
263
+ export declare function appEntry(hosting: HostingConfig): HostingEntry | undefined;
264
+ /**
265
+ * The closed set of values the plan already mints that `HostingEntry.env`
235
266
  * may map an app's env var name to (#243): the org's own Files organisation
236
267
  * id, its AI organisation id, and the mail domain its Lettermint project
237
268
  * sends from. Each is read from `StepExecutionContext.recordedIds()` in
@@ -243,12 +274,13 @@ export declare const APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
243
274
  * `null` when `hosting` is valid; otherwise the reason it is not (#71), same
244
275
  * shape as `validateBuyerIdentity` below.
245
276
  *
246
- * - `database` and `tunnel` each require `enabled` -- neither means anything
247
- * without the app itself running.
248
- * - `gitBranch` is non-empty and `buildPack` is one of `HostingBuildPack`'s
249
- * four values.
277
+ * - `tunnel` requires `enabled` -- it means nothing without the apps running.
250
278
  * - Every `issuerApps[]` entry has a non-empty `name`, unique within the
251
279
  * list, and a `kind` that is one of `IssuerAppKind`'s three values.
280
+ * - `enabled` requires an entry keyed {@link APP_ENTRY_KEY}.
281
+ * - Every `entries[]` entry follows {@link validateHostingEntry}; keys are
282
+ * unique, and at most one entry has a `database`, because the one
283
+ * `<slug>_rt` runtime role cannot serve two apps.
252
284
  */
253
285
  export declare function validateHosting(hosting: HostingConfig): string | null;
254
286
  export declare function validateDomain(domain: string): string | null;
package/dist/org.js CHANGED
@@ -2,6 +2,12 @@
2
2
  export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
3
3
  /** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
4
4
  export const DEFAULT_NO_REPLY_LOCAL_PART = 'no-reply';
5
+ /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
6
+ export const APP_ENTRY_KEY = 'app';
7
+ /** The org's `app` entry: today's one app. */
8
+ export function appEntry(hosting) {
9
+ return hosting.entries.find((entry) => entry.key === APP_ENTRY_KEY);
10
+ }
5
11
  export const APP_ENV_SOURCES = new Set([
6
12
  'filesOrganisationId',
7
13
  'aiOrganisationId',
@@ -18,34 +24,18 @@ const HOSTING_BUILD_PACKS = new Set([
18
24
  * `null` when `hosting` is valid; otherwise the reason it is not (#71), same
19
25
  * shape as `validateBuyerIdentity` below.
20
26
  *
21
- * - `database` and `tunnel` each require `enabled` -- neither means anything
22
- * without the app itself running.
23
- * - `gitBranch` is non-empty and `buildPack` is one of `HostingBuildPack`'s
24
- * four values.
27
+ * - `tunnel` requires `enabled` -- it means nothing without the apps running.
25
28
  * - Every `issuerApps[]` entry has a non-empty `name`, unique within the
26
29
  * list, and a `kind` that is one of `IssuerAppKind`'s three values.
30
+ * - `enabled` requires an entry keyed {@link APP_ENTRY_KEY}.
31
+ * - Every `entries[]` entry follows {@link validateHostingEntry}; keys are
32
+ * unique, and at most one entry has a `database`, because the one
33
+ * `<slug>_rt` runtime role cannot serve two apps.
27
34
  */
28
35
  export function validateHosting(hosting) {
29
- if (hosting.database && !hosting.enabled) {
30
- return 'hosting.database requires hosting.enabled';
31
- }
32
36
  if (hosting.tunnel && !hosting.enabled) {
33
37
  return 'hosting.tunnel requires hosting.enabled';
34
38
  }
35
- if (!hosting.gitBranch) {
36
- return 'hosting.gitBranch must be non-empty';
37
- }
38
- if (!HOSTING_BUILD_PACKS.has(hosting.buildPack)) {
39
- return `hosting.buildPack has an unknown value '${hosting.buildPack}'`;
40
- }
41
- if (hosting.accessEmails.length > 0 && !hosting.enabled) {
42
- return 'hosting.accessEmails requires hosting.enabled';
43
- }
44
- for (const email of hosting.accessEmails) {
45
- if (!/^[^@\s]+@[^@\s]+$/.test(email)) {
46
- return `hosting.accessEmails has an invalid email '${email}'`;
47
- }
48
- }
49
39
  const seenNames = new Set();
50
40
  for (const app of hosting.issuerApps) {
51
41
  if (!app.name) {
@@ -67,16 +57,98 @@ export function validateHosting(hosting) {
67
57
  return `issuerApps '${app.name}' is reserved for the always-on Agora client every org gets`;
68
58
  }
69
59
  }
70
- for (const [envName, source] of Object.entries(hosting.appEnv)) {
71
- if (!envName) {
72
- return 'every appEnv key needs a non-empty env var name';
60
+ if (hosting.enabled && !appEntry(hosting)) {
61
+ return `hosting.enabled requires an entry keyed '${APP_ENTRY_KEY}'`;
62
+ }
63
+ const seenKeys = new Set();
64
+ for (const entry of hosting.entries) {
65
+ if (seenKeys.has(entry.key)) {
66
+ return `hosting.entries key '${entry.key}' is not unique`;
67
+ }
68
+ seenKeys.add(entry.key);
69
+ const invalid = validateHostingEntry(entry, hosting.enabled);
70
+ if (invalid)
71
+ return invalid;
72
+ }
73
+ if (hosting.entries.filter((entry) => entry.database).length > 1) {
74
+ return 'at most one hosting entry may set database: the one runtime database role cannot serve two apps';
75
+ }
76
+ return null;
77
+ }
78
+ const ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
79
+ const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
80
+ // The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
81
+ const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
82
+ const SECRET_ENV_NAME = /_(KEY|SECRET|TOKEN|PASSWORD)$/;
83
+ const HELD_SECRETS = 'held secrets are not available yet';
84
+ /** The rules for one {@link HostingEntry}; `null` when it is valid. */
85
+ function validateHostingEntry(entry, enabled) {
86
+ const at = `hosting.entries '${entry.key}'`;
87
+ if (!ENTRY_KEY.test(entry.key)) {
88
+ return `hosting.entries key '${entry.key}' must be a DNS label (lowercase letters, digits and hyphens, 1-63 characters)`;
89
+ }
90
+ if (entry.repo !== null && !REPO.test(entry.repo)) {
91
+ return `${at} repo must be a repo under wtfalch, or null`;
92
+ }
93
+ if (entry.folder !== null && (entry.folder.startsWith('/') || entry.folder.includes('..'))) {
94
+ return `${at} folder must be a relative path inside the repo`;
95
+ }
96
+ if (!entry.branch) {
97
+ return `${at} branch must be non-empty`;
98
+ }
99
+ if (!HOSTING_BUILD_PACKS.has(entry.buildPack)) {
100
+ return `${at} buildPack has an unknown value '${entry.buildPack}'`;
101
+ }
102
+ if (entry.buildPack === 'dockercompose') {
103
+ return `${at} buildPack dockercompose is not available for an entry`;
104
+ }
105
+ if (entry.port !== null &&
106
+ !(Number.isInteger(entry.port) && entry.port >= 1 && entry.port <= 65535)) {
107
+ return `${at} port must be a whole number from 1 to 65535, or null`;
108
+ }
109
+ if (entry.healthPath !== null && !entry.healthPath.startsWith('/')) {
110
+ return `${at} healthPath must start with '/', or be null`;
111
+ }
112
+ if (entry.database && !enabled) {
113
+ return `${at} database requires hosting.enabled`;
114
+ }
115
+ if (entry.accessEmails.length > 0 && !enabled) {
116
+ return `${at} accessEmails requires hosting.enabled`;
117
+ }
118
+ if (entry.accessEmails.length > 0 && entry.key !== APP_ENTRY_KEY) {
119
+ return `${at} accessEmails is only available on the '${APP_ENTRY_KEY}' entry for now`;
120
+ }
121
+ for (const email of entry.accessEmails) {
122
+ if (!/^[^@\s]+@[^@\s]+$/.test(email)) {
123
+ return `${at} accessEmails has an invalid email '${email}'`;
124
+ }
125
+ }
126
+ for (const [name, value] of Object.entries(entry.env)) {
127
+ if (!name) {
128
+ return `${at} env needs a non-empty env var name`;
129
+ }
130
+ if (FOUNDRY_ENV_NAME.test(name)) {
131
+ return `${at} env '${name}' is set by foundry and cannot be declared`;
132
+ }
133
+ if (typeof value !== 'string') {
134
+ if (!APP_ENV_SOURCES.has(value?.from)) {
135
+ return `${at} env '${name}' has an unknown source '${value?.from}'`;
136
+ }
73
137
  }
74
- if (!APP_ENV_SOURCES.has(source)) {
75
- return `appEnv '${envName}' has an unknown source '${source}'`;
138
+ else if (SECRET_ENV_NAME.test(name) || (name.endsWith('_URL') && urlHasPassword(value))) {
139
+ return `${at} env '${name}' looks like a secret: ${HELD_SECRETS}`;
76
140
  }
77
141
  }
78
142
  return null;
79
143
  }
144
+ function urlHasPassword(value) {
145
+ try {
146
+ return new URL(value).password !== '';
147
+ }
148
+ catch {
149
+ return false;
150
+ }
151
+ }
80
152
  /**
81
153
  * A DNS-label-per-segment format check for `DeclaredOrg.domain` (#131): every
82
154
  * dot-separated segment follows the exact rule `../slug.ts`'s `DNS_LABEL`
package/dist/plan/diff.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
+ import { appEntry } from '../org.js';
2
3
  import { NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
3
4
  import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
4
5
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
5
- import { APP_ENV_APPLIED_SCOPE_KEY, CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, resolveAppEnv, versionNotAppliedLine, } from './platform-diff.js';
6
+ import { APP_ENV_APPLIED_SCOPE_KEY, CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appStepKey, resolveAppEnv, versionNotAppliedLine, } from './platform-diff.js';
6
7
  import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
7
8
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
8
9
  export function toPlan(detail) {
@@ -1331,8 +1332,8 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
1331
1332
  * park-not-delete rule (#7 corrections 1) -- turning it off never deletes
1332
1333
  * the org's own app, database or tunnel configuration, it parks it.
1333
1334
  */
1334
- function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark) {
1335
- const key = resourceKey(system, kind, org.id);
1335
+ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark, stepKey = org.id) {
1336
+ const key = resourceKey(system, kind, stepKey);
1336
1337
  const live = byKey.get(key);
1337
1338
  // A live resource is on unless it says it is parked. Providers' own
1338
1339
  // configs carry no `enabled` field at all, so only a park step's
@@ -1348,7 +1349,7 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
1348
1349
  if (!liveEnabled)
1349
1350
  return null;
1350
1351
  return {
1351
- action: { system, kind, key: org.id, desired: { enabled: false } },
1352
+ action: { system, kind, key: stepKey, desired: { enabled: false } },
1352
1353
  step: { description: describePark(), destroys: true, migrates: false },
1353
1354
  };
1354
1355
  }
@@ -1359,7 +1360,7 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
1359
1360
  if (liveEnabled)
1360
1361
  return null;
1361
1362
  return {
1362
- action: { system, kind, key: org.id, desired: { enabled: true } },
1363
+ action: { system, kind, key: stepKey, desired: { enabled: true } },
1363
1364
  step: { description: describeProvision(), destroys: false, migrates: false },
1364
1365
  };
1365
1366
  }
@@ -1394,8 +1395,12 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
1394
1395
  aiOrganisationId: ['ai', 'organisation'],
1395
1396
  mailDomain: ['coolify', 'mail'],
1396
1397
  };
1397
- const { values: wantedEnv, unresolved } = resolveAppEnv(org, recordedIds);
1398
- const blocked = org.hosting.enabled
1398
+ const entry = org.hosting.enabled ? appEntry(org.hosting) : undefined;
1399
+ const { values: wantedEnv, unresolved } = entry
1400
+ ? resolveAppEnv(org, entry, recordedIds)
1401
+ : { values: {}, unresolved: [] };
1402
+ const stepKey = appStepKey(org);
1403
+ const blocked = entry
1399
1404
  ? unresolved
1400
1405
  .filter(([, source]) => !plannedSteps.some((p) => p.action.system === sourceStep[source][0] &&
1401
1406
  p.action.kind === sourceStep[source][1]))
@@ -1404,18 +1409,18 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
1404
1409
  key: `coolify:app:env:${name}`,
1405
1410
  }))
1406
1411
  : [];
1407
- const toggled = planHostingToggle('coolify', 'app', org.hosting.enabled, org, byKey, claimed, () => `provision ${org.slug}'s app`, () => `park ${org.slug}'s app (stops serving; app data is retained, not deleted -- #7 corrections 1)`);
1412
+ const toggled = planHostingToggle('coolify', 'app', entry !== undefined, org, byKey, claimed, () => `provision ${org.slug}'s app`, () => `park ${org.slug}'s app (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
1408
1413
  if (toggled)
1409
1414
  return { step: toggled, blocked };
1410
- if (!org.hosting.enabled)
1415
+ if (!entry)
1411
1416
  return { step: null, blocked };
1412
- const live = byKey.get(resourceKey('coolify', 'app', org.id));
1417
+ const live = byKey.get(resourceKey('coolify', 'app', stepKey));
1413
1418
  if (!live)
1414
1419
  return { step: null, blocked };
1415
1420
  const action = {
1416
1421
  system: 'coolify',
1417
1422
  kind: 'app',
1418
- key: org.id,
1423
+ key: stepKey,
1419
1424
  desired: { enabled: true },
1420
1425
  };
1421
1426
  if (live.config.hasLogVolume === false) {
@@ -1437,7 +1442,7 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
1437
1442
  // names (`envNames` absent) reads as compliant, like `hasLogVolume`.
1438
1443
  const envNames = live.config.envNames;
1439
1444
  const dbLive = byKey.get(resourceKey('coolify', 'database', org.id));
1440
- const dbReady = org.hosting.database && Boolean(recordedIds[`coolify:database:${org.id}`]);
1445
+ const dbReady = entry.database && Boolean(recordedIds[`coolify:database:${org.id}`]);
1441
1446
  if (dbReady && dbLive?.config.hasInternalUrl === false) {
1442
1447
  // Rule 2: the push cannot succeed yet, so report it rather than replan
1443
1448
  // forever.
@@ -1466,7 +1471,7 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
1466
1471
  blocked,
1467
1472
  };
1468
1473
  }
1469
- // #243 (rule 3): a changed `hosting.appEnv` reaches an app that is already
1474
+ // #243 (rule 3): a changed entry `env` reaches an app that is already
1470
1475
  // live. `pushAppEnv` records what it pushed, so this plans the same step
1471
1476
  // again, with a redeploy, only once the resolved map differs from that.
1472
1477
  if (JSON.stringify(wantedEnv) !== (recordedIds[APP_ENV_APPLIED_SCOPE_KEY] ?? '{}')) {
@@ -1552,12 +1557,12 @@ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = n
1552
1557
  }
1553
1558
  /**
1554
1559
  * A Postgres database beside the org's app (`org.ts`'s
1555
- * `HostingConfig.database`, #71) -- `validateHosting` requires
1560
+ * `HostingEntry.database`, #71) -- `validateHosting` requires
1556
1561
  * `hosting.enabled` before this can be true. Same park-not-delete shape as
1557
1562
  * {@link planApp}.
1558
1563
  */
1559
1564
  function planDatabase(org, byKey, claimed) {
1560
- return planHostingToggle('coolify', 'database', org.hosting.database, org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`);
1565
+ return planHostingToggle('coolify', 'database', appEntry(org.hosting)?.database ?? false, org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`);
1561
1566
  }
1562
1567
  /**
1563
1568
  * A Cloudflare tunnel reaching the org's app (`org.ts`'s
@@ -1790,17 +1795,18 @@ function planCustomers(org, live, claimed, recordedIds) {
1790
1795
  }
1791
1796
  /**
1792
1797
  * #84: a Cloudflare Access gate on the org's app hostname, letting only
1793
- * `hosting.accessEmails` through. Idempotent on the recorded Access app id,
1798
+ * the `app` entry's `accessEmails` through. Idempotent on the recorded Access app id,
1794
1799
  * like `planOwner`: Cloudflare's live listing has no Access apps. A later
1795
1800
  * change to the email list is not reconciled yet.
1796
1801
  */
1797
1802
  function planAccess(org, recordedIds) {
1798
- if (!org.hosting.enabled || org.hosting.accessEmails.length === 0)
1803
+ const entry = org.hosting.enabled ? appEntry(org.hosting) : undefined;
1804
+ if (!entry || entry.accessEmails.length === 0)
1799
1805
  return null;
1800
- if (recordedIds[resourceKey('cloudflare', 'access', org.id)])
1806
+ if (recordedIds[resourceKey('cloudflare', 'access', appStepKey(org))])
1801
1807
  return null;
1802
1808
  return {
1803
- action: { system: 'cloudflare', kind: 'access', key: org.id, desired: {} },
1809
+ action: { system: 'cloudflare', kind: 'access', key: appStepKey(org), desired: {} },
1804
1810
  step: {
1805
1811
  description: `put ${org.slug}'s app behind Cloudflare Access`,
1806
1812
  destroys: false,