@wtfalch/foundry 0.8.0 → 0.10.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/declare.js +1 -5
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/migrations/0029_hosting_entries.sql +109 -0
- package/dist/migrations/0030_hosting_entry_hostnames.sql +24 -0
- package/dist/org.d.ts +87 -29
- package/dist/org.js +180 -27
- package/dist/plan/agora.d.ts +7 -0
- package/dist/plan/agora.js +9 -0
- package/dist/plan/broker-entries.d.ts +104 -0
- package/dist/plan/broker-entries.js +214 -0
- package/dist/plan/diff.d.ts +2 -0
- package/dist/plan/diff.js +274 -50
- package/dist/plan/executor.d.ts +89 -29
- package/dist/plan/executor.js +495 -233
- package/dist/plan/hostnames.d.ts +24 -0
- package/dist/plan/hostnames.js +56 -0
- package/dist/plan/index.d.ts +3 -1
- package/dist/plan/index.js +2 -1
- package/dist/plan/locked-hostnames.d.ts +8 -0
- package/dist/plan/locked-hostnames.js +26 -0
- package/dist/plan/login-host.d.ts +1 -1
- package/dist/plan/login-host.js +5 -2
- package/dist/plan/plan-apply.d.ts +16 -0
- package/dist/plan/plan-apply.js +48 -11
- package/dist/plan/platform-diff.d.ts +69 -10
- package/dist/plan/platform-diff.js +133 -14
- package/dist/plan/platform-providers.d.ts +102 -2
- package/dist/plan/platform-providers.js +486 -40
- package/dist/plan/zitadel-system-api.d.ts +64 -0
- package/dist/plan/zitadel-system-api.js +125 -0
- package/dist/platform.d.ts +1 -1
- package/dist/store/postgres.js +12 -12
- package/dist/store/schema.d.ts +20 -1
- package/dist/store/schema.js +7 -1
- package/package.json +1 -1
|
@@ -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, HostingEntryUses, EntryAuth, 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;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- @wtfalch/foundry: each hosting entry names where it is served (org.ts's
|
|
2
|
+
-- `HostingEntry.hostnames`). Mirrors `src/store/schema.ts`'s `hostingEntries`.
|
|
3
|
+
--
|
|
4
|
+
-- The `app` entry of an org with hosting.usesDomain on is served at the org's
|
|
5
|
+
-- domain, so it gets ['@']; every other entry, and the `app` entry of an org
|
|
6
|
+
-- with usesDomain off (served at <slug>.oikos.host), gets []. An org planned
|
|
7
|
+
-- before this migration therefore plans the same names after it.
|
|
8
|
+
--
|
|
9
|
+
-- Copies and never drops: no column changes, only entries without a
|
|
10
|
+
-- `hostnames` field gain one. Safe to run twice: an entry that has the field
|
|
11
|
+
-- is left as it is.
|
|
12
|
+
|
|
13
|
+
update foundry_declared_orgs o
|
|
14
|
+
set hosting_entries = (
|
|
15
|
+
select coalesce(jsonb_agg(
|
|
16
|
+
case when e ? 'hostnames' then e
|
|
17
|
+
else e || jsonb_build_object(
|
|
18
|
+
'hostnames',
|
|
19
|
+
case when e ->> 'key' = 'app' and o.hosting_uses_domain
|
|
20
|
+
then '["@"]'::jsonb else '[]'::jsonb end)
|
|
21
|
+
end order by t.ord), '[]'::jsonb)
|
|
22
|
+
from jsonb_array_elements(o.hosting_entries) with ordinality as t(e, ord))
|
|
23
|
+
where exists (
|
|
24
|
+
select 1 from jsonb_array_elements(o.hosting_entries) e where not (e ? 'hostnames'));
|
package/dist/org.d.ts
CHANGED
|
@@ -184,54 +184,108 @@ export interface IssuerApp {
|
|
|
184
184
|
*/
|
|
185
185
|
export type HostingBuildPack = 'dockerfile' | 'nixpacks' | 'static' | 'dockercompose';
|
|
186
186
|
/**
|
|
187
|
-
* The org'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
|
-
* `
|
|
195
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
202
199
|
*/
|
|
203
200
|
export interface HostingConfig {
|
|
204
|
-
/** The org's
|
|
201
|
+
/** The org's apps run on Coolify. */
|
|
205
202
|
enabled: boolean;
|
|
206
203
|
/** Served on `DeclaredOrg.domain` (and its Cloudflare zone managed) instead of `<slug>.oikos.host` (`../slug.ts`). */
|
|
207
204
|
usesDomain: boolean;
|
|
208
|
-
/**
|
|
209
|
-
database: boolean;
|
|
210
|
-
/** The app is reached through a Cloudflare tunnel. Requires `enabled`. */
|
|
205
|
+
/** The apps are reached through a Cloudflare tunnel. Requires `enabled`. */
|
|
211
206
|
tunnel: boolean;
|
|
207
|
+
/** The org's declared ZITADEL OIDC applications (#71). */
|
|
208
|
+
issuerApps: IssuerApp[];
|
|
209
|
+
/** One entry per app the org runs. */
|
|
210
|
+
entries: HostingEntry[];
|
|
211
|
+
}
|
|
212
|
+
/** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
|
|
213
|
+
export declare const APP_ENTRY_KEY = "app";
|
|
214
|
+
/**
|
|
215
|
+
* One app the org runs. `database` and `accessEmails` are meaningless
|
|
216
|
+
* without `HostingConfig.enabled`, so `validateHosting` requires it before
|
|
217
|
+
* either is set.
|
|
218
|
+
*/
|
|
219
|
+
export interface HostingEntry {
|
|
220
|
+
/** A DNS label, unique per org. It goes into step ids and scope keys, so it never holds `:` or `.`. */
|
|
221
|
+
key: string;
|
|
222
|
+
/** The repo under `wtfalch` the app builds from. `null` is the shared standard app (`executor.ts`'s `DEFAULT_APP_REPO`). */
|
|
223
|
+
repo: string | null;
|
|
224
|
+
/** The app's folder in `repo`. `null` is the repo root. */
|
|
225
|
+
folder: string | null;
|
|
212
226
|
/** The branch `coolify:app` builds and deploys from. Default: `'main'`. */
|
|
213
|
-
|
|
227
|
+
branch: string;
|
|
214
228
|
/** The Coolify build strategy `coolify:app` provisions with. Default: `'nixpacks'`. */
|
|
215
229
|
buildPack: HostingBuildPack;
|
|
216
|
-
/** The
|
|
217
|
-
|
|
230
|
+
/** The port the app listens on. `null` is Coolify's default. */
|
|
231
|
+
port: number | null;
|
|
232
|
+
/** The path that answers 200 when the app is healthy. `null` is none. */
|
|
233
|
+
healthPath: string | null;
|
|
234
|
+
/**
|
|
235
|
+
* The names the entry is served at: `'@'` (the org's domain), a label under
|
|
236
|
+
* the domain, or `'*'` (reserved: a later change acts on it). Empty means an
|
|
237
|
+
* internal app, reached as `<slug>-<key>` on the Coolify network. Read only
|
|
238
|
+
* when `HostingConfig.usesDomain` is on and the org has a domain; otherwise
|
|
239
|
+
* the `app` entry is served at `<slug>.oikos.host` and no other entry may
|
|
240
|
+
* set any.
|
|
241
|
+
*/
|
|
242
|
+
hostnames: string[];
|
|
243
|
+
/** A Postgres database beside the app. At most one entry per org. */
|
|
244
|
+
database: boolean;
|
|
245
|
+
/** Deploy on every push to `branch`. Default: `true`. */
|
|
246
|
+
autoDeploy: boolean;
|
|
218
247
|
/**
|
|
219
248
|
* Emails Cloudflare Access lets through to the app's hostname (#84). Empty
|
|
220
|
-
* means no Access gate.
|
|
249
|
+
* means no Access gate. Only the entry keyed {@link APP_ENTRY_KEY} may set it.
|
|
221
250
|
*/
|
|
222
251
|
accessEmails: string[];
|
|
223
252
|
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
* them, so `pushAppEnv` pushes nothing from this map.
|
|
253
|
+
* The app's own env var names and what they hold (#243). Each bespoke app
|
|
254
|
+
* names the values the plan mints (Files, AI, mail) differently (lokessmie
|
|
255
|
+
* uses `STORAGE_*`/`AI_*`); `{ from }` declares its own name for one. `{}`
|
|
256
|
+
* means the app names none of them, so `pushAppEnv` pushes nothing from
|
|
257
|
+
* this map.
|
|
230
258
|
*/
|
|
231
|
-
|
|
259
|
+
env: Record<string, EnvValue>;
|
|
260
|
+
/** What the entry signs in through. Absent means it uses nothing. */
|
|
261
|
+
uses?: HostingEntryUses;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Sign-in for one entry: one ZITADEL client and one broker entry, signing in
|
|
265
|
+
* ONE organisation. `staff` is the org's own ZITADEL org, `customers` the
|
|
266
|
+
* `<slug> customers` org. `registration` is the broker entry's sign-up switch.
|
|
267
|
+
* `replacesBrokerEntry` is the `name` of the hand-written broker `AUTH_APPS`
|
|
268
|
+
* entry this entry's own entry replaces; the first write removes it.
|
|
269
|
+
*/
|
|
270
|
+
export interface EntryAuth {
|
|
271
|
+
users: 'staff' | 'customers';
|
|
272
|
+
registration: 'open' | 'closed';
|
|
273
|
+
replacesBrokerEntry?: string;
|
|
274
|
+
}
|
|
275
|
+
export interface HostingEntryUses {
|
|
276
|
+
auth?: EntryAuth;
|
|
232
277
|
}
|
|
233
278
|
/**
|
|
234
|
-
*
|
|
279
|
+
* A plain string is pushed as it is, so `validateHosting` refuses one that
|
|
280
|
+
* looks like a secret. `{ from }` names a value the plan already mints.
|
|
281
|
+
*/
|
|
282
|
+
export type EnvValue = string | {
|
|
283
|
+
from: AppEnvSource;
|
|
284
|
+
};
|
|
285
|
+
/** The org's `app` entry: today's one app. */
|
|
286
|
+
export declare function appEntry(hosting: HostingConfig): HostingEntry | undefined;
|
|
287
|
+
/**
|
|
288
|
+
* The closed set of values the plan already mints that `HostingEntry.env`
|
|
235
289
|
* may map an app's env var name to (#243): the org's own Files organisation
|
|
236
290
|
* id, its AI organisation id, and the mail domain its Lettermint project
|
|
237
291
|
* sends from. Each is read from `StepExecutionContext.recordedIds()` in
|
|
@@ -243,14 +297,18 @@ export declare const APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
|
|
|
243
297
|
* `null` when `hosting` is valid; otherwise the reason it is not (#71), same
|
|
244
298
|
* shape as `validateBuyerIdentity` below.
|
|
245
299
|
*
|
|
246
|
-
* - `
|
|
247
|
-
* without the app itself running.
|
|
248
|
-
* - `gitBranch` is non-empty and `buildPack` is one of `HostingBuildPack`'s
|
|
249
|
-
* four values.
|
|
300
|
+
* - `tunnel` requires `enabled` -- it means nothing without the apps running.
|
|
250
301
|
* - Every `issuerApps[]` entry has a non-empty `name`, unique within the
|
|
251
302
|
* list, and a `kind` that is one of `IssuerAppKind`'s three values.
|
|
303
|
+
* - `enabled` requires an entry keyed {@link APP_ENTRY_KEY}.
|
|
304
|
+
* - Every `entries[]` entry follows {@link validateHostingEntry}; keys are
|
|
305
|
+
* unique, and at most one entry has a `database`, because the one
|
|
306
|
+
* `<slug>_rt` runtime role cannot serve two apps.
|
|
252
307
|
*/
|
|
253
|
-
export declare function validateHosting(hosting: HostingConfig
|
|
308
|
+
export declare function validateHosting(hosting: HostingConfig, names?: {
|
|
309
|
+
domain: string | null;
|
|
310
|
+
locked: ReadonlySet<string>;
|
|
311
|
+
}): string | null;
|
|
254
312
|
export declare function validateDomain(domain: string): string | null;
|
|
255
313
|
/**
|
|
256
314
|
* `null` when `site` is valid; otherwise the reason it is not (#101).
|
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
|
-
* - `
|
|
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
|
-
export function validateHosting(hosting) {
|
|
29
|
-
if (hosting.database && !hosting.enabled) {
|
|
30
|
-
return 'hosting.database requires hosting.enabled';
|
|
31
|
-
}
|
|
35
|
+
export function validateHosting(hosting, names) {
|
|
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,179 @@ 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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
const invalidAuth = validateEntryAuth(entry, hosting, names?.domain);
|
|
73
|
+
if (invalidAuth)
|
|
74
|
+
return invalidAuth;
|
|
75
|
+
}
|
|
76
|
+
const invalidNames = validateEntryHostnames(hosting, names);
|
|
77
|
+
if (invalidNames)
|
|
78
|
+
return invalidNames;
|
|
79
|
+
if (hosting.entries.filter((entry) => entry.database).length > 1) {
|
|
80
|
+
return 'at most one hosting entry may set database: the one runtime database role cannot serve two apps';
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The rules for the names of every entry: each is `@`, `*` or a DNS label, no
|
|
86
|
+
* name twice, none on a name foundry owns (`plan/locked-hostnames.ts`), and
|
|
87
|
+
* none at all on a non-`app` entry while `usesDomain` is off. `names` is what
|
|
88
|
+
* the caller knows of the org; without it only the checks that need no domain
|
|
89
|
+
* run.
|
|
90
|
+
*/
|
|
91
|
+
function validateEntryHostnames(hosting, names) {
|
|
92
|
+
const taken = new Set();
|
|
93
|
+
for (const entry of hosting.entries) {
|
|
94
|
+
const at = `hosting.entries '${entry.key}'`;
|
|
95
|
+
if (!hosting.usesDomain && entry.key !== APP_ENTRY_KEY && entry.hostnames.length > 0) {
|
|
96
|
+
return `${at} hostnames need hosting.usesDomain: without it only the '${APP_ENTRY_KEY}' entry is served, at its own address`;
|
|
97
|
+
}
|
|
98
|
+
const reads = hosting.usesDomain && names?.domain;
|
|
99
|
+
if (reads && entry.key === APP_ENTRY_KEY && !entry.hostnames.some((n) => n !== '*')) {
|
|
100
|
+
return `${at} needs a hostname ('@' or a label) while hosting.usesDomain is on: '*' alone names nothing, and the app would fall back to the bare domain`;
|
|
101
|
+
}
|
|
102
|
+
for (const name of entry.hostnames) {
|
|
103
|
+
if (name !== '@' && name !== '*' && !ENTRY_KEY.test(name)) {
|
|
104
|
+
return `${at} hostname '${name}' must be '@', '*' or a DNS label`;
|
|
105
|
+
}
|
|
106
|
+
if (taken.has(name))
|
|
107
|
+
return `hostname '${name}' is held by two entries or listed twice`;
|
|
108
|
+
taken.add(name);
|
|
109
|
+
if (names?.domain &&
|
|
110
|
+
name !== '@' &&
|
|
111
|
+
name !== '*' &&
|
|
112
|
+
names.locked.has(`${name}.${names.domain}`)) {
|
|
113
|
+
return `${at} hostname '${name}' is a name foundry owns: ${name}.${names.domain}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
120
|
+
const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
|
|
121
|
+
// The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
|
|
122
|
+
const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
|
|
123
|
+
const SECRET_ENV_NAME = /_(KEY|SECRET|TOKEN|PASSWORD)$/;
|
|
124
|
+
const HELD_SECRETS = 'held secrets are not available yet';
|
|
125
|
+
/** The rules for one {@link HostingEntry}; `null` when it is valid. */
|
|
126
|
+
function validateHostingEntry(entry, enabled) {
|
|
127
|
+
const at = `hosting.entries '${entry.key}'`;
|
|
128
|
+
if (!ENTRY_KEY.test(entry.key)) {
|
|
129
|
+
return `hosting.entries key '${entry.key}' must be a DNS label (lowercase letters, digits and hyphens, 1-63 characters)`;
|
|
130
|
+
}
|
|
131
|
+
if (entry.repo !== null && !REPO.test(entry.repo)) {
|
|
132
|
+
return `${at} repo must be a repo under wtfalch, or null`;
|
|
133
|
+
}
|
|
134
|
+
if (entry.folder !== null && (entry.folder.startsWith('/') || entry.folder.includes('..'))) {
|
|
135
|
+
return `${at} folder must be a relative path inside the repo`;
|
|
136
|
+
}
|
|
137
|
+
if (!entry.branch) {
|
|
138
|
+
return `${at} branch must be non-empty`;
|
|
139
|
+
}
|
|
140
|
+
if (!HOSTING_BUILD_PACKS.has(entry.buildPack)) {
|
|
141
|
+
return `${at} buildPack has an unknown value '${entry.buildPack}'`;
|
|
142
|
+
}
|
|
143
|
+
if (entry.buildPack === 'dockercompose') {
|
|
144
|
+
return `${at} buildPack dockercompose is not available for an entry`;
|
|
145
|
+
}
|
|
146
|
+
if (entry.port !== null &&
|
|
147
|
+
!(Number.isInteger(entry.port) && entry.port >= 1 && entry.port <= 65535)) {
|
|
148
|
+
return `${at} port must be a whole number from 1 to 65535, or null`;
|
|
149
|
+
}
|
|
150
|
+
if (entry.healthPath !== null && !entry.healthPath.startsWith('/')) {
|
|
151
|
+
return `${at} healthPath must start with '/', or be null`;
|
|
152
|
+
}
|
|
153
|
+
if (entry.database && !enabled) {
|
|
154
|
+
return `${at} database requires hosting.enabled`;
|
|
155
|
+
}
|
|
156
|
+
if (entry.accessEmails.length > 0 && !enabled) {
|
|
157
|
+
return `${at} accessEmails requires hosting.enabled`;
|
|
158
|
+
}
|
|
159
|
+
if (entry.accessEmails.length > 0 && entry.key !== APP_ENTRY_KEY) {
|
|
160
|
+
return `${at} accessEmails is only available on the '${APP_ENTRY_KEY}' entry for now`;
|
|
161
|
+
}
|
|
162
|
+
for (const email of entry.accessEmails) {
|
|
163
|
+
if (!/^[^@\s]+@[^@\s]+$/.test(email)) {
|
|
164
|
+
return `${at} accessEmails has an invalid email '${email}'`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const [name, value] of Object.entries(entry.env)) {
|
|
168
|
+
if (!name) {
|
|
169
|
+
return `${at} env needs a non-empty env var name`;
|
|
170
|
+
}
|
|
171
|
+
if (FOUNDRY_ENV_NAME.test(name)) {
|
|
172
|
+
return `${at} env '${name}' is set by foundry and cannot be declared`;
|
|
173
|
+
}
|
|
174
|
+
if (typeof value !== 'string') {
|
|
175
|
+
if (!APP_ENV_SOURCES.has(value?.from)) {
|
|
176
|
+
return `${at} env '${name}' has an unknown source '${value?.from}'`;
|
|
177
|
+
}
|
|
73
178
|
}
|
|
74
|
-
if (
|
|
75
|
-
return
|
|
179
|
+
else if (SECRET_ENV_NAME.test(name) || (name.endsWith('_URL') && urlHasPassword(value))) {
|
|
180
|
+
return `${at} env '${name}' looks like a secret: ${HELD_SECRETS}`;
|
|
76
181
|
}
|
|
77
182
|
}
|
|
78
183
|
return null;
|
|
79
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* `uses.auth`: its two switches are known values, sign-up is never open
|
|
187
|
+
* into the staff organisation, the entry has an exact
|
|
188
|
+
* hostname to sign in at (a `*` or an internal entry has none), and the
|
|
189
|
+
* client's name, which is the entry key, is not one another client holds.
|
|
190
|
+
*/
|
|
191
|
+
function validateEntryAuth(entry, hosting, domain) {
|
|
192
|
+
const auth = entry.uses?.auth;
|
|
193
|
+
if (!auth)
|
|
194
|
+
return null;
|
|
195
|
+
const at = `hosting.entries '${entry.key}' uses.auth`;
|
|
196
|
+
if (!hosting.enabled)
|
|
197
|
+
return `${at} requires hosting.enabled`;
|
|
198
|
+
if (auth.users !== 'staff' && auth.users !== 'customers') {
|
|
199
|
+
return `${at} users must be 'staff' or 'customers'`;
|
|
200
|
+
}
|
|
201
|
+
if (auth.registration !== 'open' && auth.registration !== 'closed') {
|
|
202
|
+
return `${at} registration must be 'open' or 'closed'`;
|
|
203
|
+
}
|
|
204
|
+
// The staff organisation holds the org's own people and its project.
|
|
205
|
+
if (auth.users === 'staff' && auth.registration === 'open') {
|
|
206
|
+
return `${at} registration 'open' needs users 'customers': nobody signs themselves up as staff`;
|
|
207
|
+
}
|
|
208
|
+
if (auth.replacesBrokerEntry !== undefined &&
|
|
209
|
+
(typeof auth.replacesBrokerEntry !== 'string' || auth.replacesBrokerEntry === '')) {
|
|
210
|
+
return `${at} replacesBrokerEntry must be the non-empty name of a broker entry`;
|
|
211
|
+
}
|
|
212
|
+
// With no domain the hostnames are not read (`entryHostnames`).
|
|
213
|
+
const hasName = hosting.usesDomain && domain !== null
|
|
214
|
+
? entry.hostnames.some((name) => name !== '*')
|
|
215
|
+
: entry.key === APP_ENTRY_KEY;
|
|
216
|
+
if (!hasName)
|
|
217
|
+
return `${at} needs an exact hostname to sign in at`;
|
|
218
|
+
if (entry.key === 'agora' ||
|
|
219
|
+
entry.key === 'cms' ||
|
|
220
|
+
hosting.issuerApps.some((app) => app.name === entry.key)) {
|
|
221
|
+
return `${at} client name '${entry.key}' is already held by another ZITADEL client`;
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
function urlHasPassword(value) {
|
|
226
|
+
try {
|
|
227
|
+
return new URL(value).password !== '';
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
80
233
|
/**
|
|
81
234
|
* A DNS-label-per-segment format check for `DeclaredOrg.domain` (#131): every
|
|
82
235
|
* dot-separated segment follows the exact rule `../slug.ts`'s `DNS_LABEL`
|
package/dist/plan/agora.d.ts
CHANGED
|
@@ -24,6 +24,13 @@ export declare const AGORA_CLIENT_ID_SCOPE_KEY = "agora_client_id";
|
|
|
24
24
|
* report).
|
|
25
25
|
*/
|
|
26
26
|
export declare const APP_CLIENT_ID_SCOPE_KEY = "zitadel_app_client_id";
|
|
27
|
+
/**
|
|
28
|
+
* An entry with `uses.auth` has its own client, recorded under
|
|
29
|
+
* `zitadel_app_client_id.<entry key>` (a scope key holds no `:`). Not a
|
|
30
|
+
* secret. The entry key is also the client's name in the org's project.
|
|
31
|
+
*/
|
|
32
|
+
export declare const ENTRY_CLIENT_ID_SCOPE_PREFIX = "zitadel_app_client_id.";
|
|
33
|
+
export declare function entryClientIdScopeKey(entryKey: string): string;
|
|
27
34
|
/**
|
|
28
35
|
* #139: the `cms` app's own ZITADEL web client's `clientId`, recorded
|
|
29
36
|
* separately from {@link APP_CLIENT_ID_SCOPE_KEY} -- that key belongs to the
|
package/dist/plan/agora.js
CHANGED
|
@@ -23,6 +23,15 @@ export const AGORA_CLIENT_ID_SCOPE_KEY = 'agora_client_id';
|
|
|
23
23
|
* report).
|
|
24
24
|
*/
|
|
25
25
|
export const APP_CLIENT_ID_SCOPE_KEY = 'zitadel_app_client_id';
|
|
26
|
+
/**
|
|
27
|
+
* An entry with `uses.auth` has its own client, recorded under
|
|
28
|
+
* `zitadel_app_client_id.<entry key>` (a scope key holds no `:`). Not a
|
|
29
|
+
* secret. The entry key is also the client's name in the org's project.
|
|
30
|
+
*/
|
|
31
|
+
export const ENTRY_CLIENT_ID_SCOPE_PREFIX = `${APP_CLIENT_ID_SCOPE_KEY}.`;
|
|
32
|
+
export function entryClientIdScopeKey(entryKey) {
|
|
33
|
+
return `${ENTRY_CLIENT_ID_SCOPE_PREFIX}${entryKey}`;
|
|
34
|
+
}
|
|
26
35
|
/**
|
|
27
36
|
* #139: the `cms` app's own ZITADEL web client's `clientId`, recorded
|
|
28
37
|
* separately from {@link APP_CLIENT_ID_SCOPE_KEY} -- that key belongs to the
|