@wtfalch/foundry 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/apply.d.ts +16 -0
  2. package/dist/index.d.ts +4 -3
  3. package/dist/index.js +1 -1
  4. package/dist/migrations/0030_hosting_entry_hostnames.sql +24 -0
  5. package/dist/migrations/0031_adoptions.sql +37 -0
  6. package/dist/org.d.ts +49 -4
  7. package/dist/org.js +134 -1
  8. package/dist/plan/adopt.d.ts +112 -0
  9. package/dist/plan/adopt.js +252 -0
  10. package/dist/plan/adoption-vault.d.ts +9 -0
  11. package/dist/plan/adoption-vault.js +1 -0
  12. package/dist/plan/agora.d.ts +7 -0
  13. package/dist/plan/agora.js +9 -0
  14. package/dist/plan/broker-entries.d.ts +108 -0
  15. package/dist/plan/broker-entries.js +214 -0
  16. package/dist/plan/cloudflared-connector.d.ts +10 -2
  17. package/dist/plan/cloudflared-connector.js +24 -16
  18. package/dist/plan/diff.d.ts +14 -0
  19. package/dist/plan/diff.js +488 -47
  20. package/dist/plan/executor.d.ts +141 -3
  21. package/dist/plan/executor.js +1100 -149
  22. package/dist/plan/hostnames.d.ts +24 -0
  23. package/dist/plan/hostnames.js +56 -0
  24. package/dist/plan/index.d.ts +2 -0
  25. package/dist/plan/index.js +1 -0
  26. package/dist/plan/locked-hostnames.d.ts +8 -0
  27. package/dist/plan/locked-hostnames.js +26 -0
  28. package/dist/plan/login-host.d.ts +1 -1
  29. package/dist/plan/login-host.js +5 -2
  30. package/dist/plan/plan-apply.d.ts +63 -3
  31. package/dist/plan/plan-apply.js +163 -5
  32. package/dist/plan/platform-diff.d.ts +66 -13
  33. package/dist/plan/platform-diff.js +117 -15
  34. package/dist/plan/platform-providers.d.ts +95 -2
  35. package/dist/plan/platform-providers.js +393 -27
  36. package/dist/plan/providers-readers.d.ts +8 -1
  37. package/dist/plan/providers-readers.js +58 -4
  38. package/dist/plan.d.ts +6 -0
  39. package/dist/store/postgres.d.ts +6 -1
  40. package/dist/store/postgres.js +80 -5
  41. package/dist/store/schema.d.ts +162 -0
  42. package/dist/store/schema.js +19 -1
  43. package/dist/store.d.ts +37 -0
  44. package/package.json +1 -1
package/dist/apply.d.ts CHANGED
@@ -11,6 +11,13 @@ import type { AppliedState } from './state.js';
11
11
  * `@wtfalch/audit` records every plan and apply — who, when, the plan, the
12
12
  * result.
13
13
  */
14
+ /** What {@link PlanApply.restore} put back: resource keys and env names, never a value. */
15
+ export interface AdoptionRestored {
16
+ restored: string[];
17
+ envDeleted: string[];
18
+ /** Connector rows whose TUNNEL_TOKEN was left because it is not the one foundry wrote. */
19
+ tokenLeft?: string[];
20
+ }
14
21
  export interface PlanApply {
15
22
  /** Reads live state from Zitadel, Coolify and Cloudflare and diffs it
16
23
  * against the declared org (#19 "Done when"). */
@@ -38,4 +45,13 @@ export interface PlanApply {
38
45
  * `destroy` only tears down what's left.
39
46
  */
40
47
  destroy(id: OrgId, confirmedOrgName: string): Promise<void>;
48
+ /**
49
+ * Puts an adopted app back as the snapshot recorded it (#311): the settings
50
+ * it had, its old env values, and none of the env names foundry added. Marks
51
+ * each adoption `restored`. Refused unless `confirmedOrgName` matches the
52
+ * org's `displayName`, once the snapshot is `confirmed` (the first
53
+ * successful apply after the adopting one), and, before any write, when the
54
+ * snapshot's vault entry is missing.
55
+ */
56
+ restore(id: OrgId, confirmedOrgName: string): Promise<AdoptionRestored>;
41
57
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
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';
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
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';
@@ -8,9 +8,10 @@ export type { SlugRejectionReason, SlugRejection } from './slug.js';
8
8
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
9
9
  export type { Checkpoint, LastApplied, AppliedState } from './state.js';
10
10
  export type { PlanStep, UnmanagedItem, Plan } from './plan.js';
11
- export type { DeclaredOrgStore, PlatformStore, RequestedChangeStore } from './store.js';
11
+ export type { Adoption, AdoptionKind, AdoptionState, AdoptionStore, DeclaredOrgStore, PlatformStore, RequestedChangeStore, } from './store.js';
12
+ export type { AdoptionVault } from './plan/adoption-vault.js';
12
13
  export type { PlanApply } from './apply.js';
13
- export { createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
14
+ export { createPostgresAdoptionStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
14
15
  export { createPostgresAuditLog } from './store/audit.js';
15
16
  export type { FoundryStoreDb } from './store/postgres.js';
16
17
  export { declaredOrgs, foundryAppliedState, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ export { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, appEntry, validateBuyerIdentity, vali
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';
5
- export { createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
5
+ export { createPostgresAdoptionStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
6
6
  export { createPostgresAuditLog } from './store/audit.js';
7
7
  export { declaredOrgs, foundryAppliedState, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
8
8
  export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
@@ -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'));
@@ -0,0 +1,37 @@
1
+ -- @wtfalch/foundry: what foundry recorded before it first changed a live
2
+ -- resource it took over in place (#311), and the org's declared live
3
+ -- database. Mirrors `src/store/schema.ts`'s `foundryAdoptions` and
4
+ -- `declaredOrgs.hostingAdopt`.
5
+ --
6
+ -- `settings` holds non-secret settings only. The secret part of the snapshot
7
+ -- (env values) lives in the host's vault under `vault_ref`; no env value, URL
8
+ -- or secret is ever written here. `kind` lists every kind the later slices add
9
+ -- (tunnel, connector, access) so none of them needs to change this check.
10
+ -- `state` moves adopted -> confirmed or restored or released. A row goes with
11
+ -- its org (`on delete cascade`), so destroying a test org needs no extra step.
12
+ --
13
+ -- `hosting_adopt` is `HostingConfig.adopt`, null when the org adopts nothing.
14
+ -- An entry's own `adopt` lives inside `hosting_entries` and needs no column.
15
+ --
16
+ -- Safe to run twice: nothing is dropped or rewritten.
17
+
18
+ create table if not exists foundry_adoptions (
19
+ org_id text not null,
20
+ resource_key text not null,
21
+ kind text not null,
22
+ taken_at timestamptz not null,
23
+ settings jsonb not null default '{}'::jsonb,
24
+ vault_ref text,
25
+ state text not null default 'adopted',
26
+
27
+ constraint foundry_adoptions_pkey primary key (org_id, resource_key),
28
+ constraint foundry_adoptions_org_fkey
29
+ foreign key (org_id) references foundry_declared_orgs (id) on delete cascade,
30
+ constraint foundry_adoptions_kind_check
31
+ check (kind in ('app', 'database', 'tunnel', 'connector', 'access')),
32
+ constraint foundry_adoptions_state_check
33
+ check (state in ('adopted', 'confirmed', 'restored', 'released'))
34
+ );
35
+
36
+ alter table foundry_declared_orgs
37
+ add column if not exists hosting_adopt jsonb;
package/dist/org.d.ts CHANGED
@@ -196,9 +196,6 @@ export type HostingBuildPack = 'dockerfile' | 'nixpacks' | 'static' | 'dockercom
196
196
  * its apps. Each app is one of `entries`. `tunnel` is meaningless without the
197
197
  * apps running, so `validateHosting` below requires `enabled` before it can
198
198
  * be true.
199
- *
200
- * Until the planner reads more than one entry, only the entry keyed
201
- * {@link APP_ENTRY_KEY} is planned.
202
199
  */
203
200
  export interface HostingConfig {
204
201
  /** The org's apps run on Coolify. */
@@ -211,6 +208,15 @@ export interface HostingConfig {
211
208
  issuerApps: IssuerApp[];
212
209
  /** One entry per app the org runs. */
213
210
  entries: HostingEntry[];
211
+ /** Live resources foundry takes over in place instead of creating. */
212
+ adopt?: {
213
+ /** The Coolify uuid of the org's live database, taken over as `<slug>-db`. */
214
+ database?: string;
215
+ /** The id of the org's live Cloudflare tunnel: its routes are merged, never overwritten (#312). Requires `tunnel`. */
216
+ tunnel?: string;
217
+ /** The Coolify uuid of the live `cloudflared` app that dials into `tunnel` (#312). Requires `tunnel`. */
218
+ connectorApp?: string;
219
+ };
214
220
  }
215
221
  /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
216
222
  export declare const APP_ENTRY_KEY = "app";
@@ -234,6 +240,15 @@ export interface HostingEntry {
234
240
  port: number | null;
235
241
  /** The path that answers 200 when the app is healthy. `null` is none. */
236
242
  healthPath: string | null;
243
+ /**
244
+ * The names the entry is served at: `'@'` (the org's domain), a label under
245
+ * the domain, or `'*'` (reserved: a later change acts on it). Empty means an
246
+ * internal app, reached as `<slug>-<key>` on the Coolify network. Read only
247
+ * when `HostingConfig.usesDomain` is on and the org has a domain; otherwise
248
+ * the `app` entry is served at `<slug>.oikos.host` and no other entry may
249
+ * set any.
250
+ */
251
+ hostnames: string[];
237
252
  /** A Postgres database beside the app. At most one entry per org. */
238
253
  database: boolean;
239
254
  /** Deploy on every push to `branch`. Default: `true`. */
@@ -251,6 +266,27 @@ export interface HostingEntry {
251
266
  * this map.
252
267
  */
253
268
  env: Record<string, EnvValue>;
269
+ /** Takes over the live Coolify app with this uuid instead of creating one. */
270
+ adopt?: {
271
+ app: string;
272
+ };
273
+ /** What the entry signs in through. Absent means it uses nothing. */
274
+ uses?: HostingEntryUses;
275
+ }
276
+ /**
277
+ * Sign-in for one entry: one ZITADEL client and one broker entry, signing in
278
+ * ONE organisation. `staff` is the org's own ZITADEL org, `customers` the
279
+ * `<slug> customers` org. `registration` is the broker entry's sign-up switch.
280
+ * `replacesBrokerEntry` is the `name` of the hand-written broker `AUTH_APPS`
281
+ * entry this entry's own entry replaces; the first write removes it.
282
+ */
283
+ export interface EntryAuth {
284
+ users: 'staff' | 'customers';
285
+ registration: 'open' | 'closed';
286
+ replacesBrokerEntry?: string;
287
+ }
288
+ export interface HostingEntryUses {
289
+ auth?: EntryAuth;
254
290
  }
255
291
  /**
256
292
  * A plain string is pushed as it is, so `validateHosting` refuses one that
@@ -281,8 +317,17 @@ export declare const APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
281
317
  * - Every `entries[]` entry follows {@link validateHostingEntry}; keys are
282
318
  * unique, and at most one entry has a `database`, because the one
283
319
  * `<slug>_rt` runtime role cannot serve two apps.
320
+ * - An adopted uuid (`entries[].adopt.app`, `adopt.database`) is non-empty
321
+ * and only lowercase letters and digits (it goes into a URL path), and no two
322
+ * entries adopt the same app. `adopt.database` needs an entry with `database`.
323
+ * - `adopt.tunnel` is a Cloudflare tunnel id (a UUID), `adopt.connectorApp` a
324
+ * Coolify uuid that no entry or database adopts. `connectorApp` needs
325
+ * `tunnel`, and both need `hosting.tunnel` (#312).
284
326
  */
285
- export declare function validateHosting(hosting: HostingConfig): string | null;
327
+ export declare function validateHosting(hosting: HostingConfig, names?: {
328
+ domain: string | null;
329
+ locked: ReadonlySet<string>;
330
+ }): string | null;
286
331
  export declare function validateDomain(domain: string): string | null;
287
332
  /**
288
333
  * `null` when `site` is valid; otherwise the reason it is not (#101).
package/dist/org.js CHANGED
@@ -13,6 +13,9 @@ export const APP_ENV_SOURCES = new Set([
13
13
  'aiOrganisationId',
14
14
  'mailDomain',
15
15
  ]);
16
+ const ADOPT_UUID = /^[a-z0-9]+$/;
17
+ /** A Cloudflare tunnel id: a lowercase UUID with hyphens (#312). */
18
+ const TUNNEL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
16
19
  const ISSUER_APP_KINDS = new Set(['web', 'native', 'api']);
17
20
  const HOSTING_BUILD_PACKS = new Set([
18
21
  'dockerfile',
@@ -31,8 +34,14 @@ const HOSTING_BUILD_PACKS = new Set([
31
34
  * - Every `entries[]` entry follows {@link validateHostingEntry}; keys are
32
35
  * unique, and at most one entry has a `database`, because the one
33
36
  * `<slug>_rt` runtime role cannot serve two apps.
37
+ * - An adopted uuid (`entries[].adopt.app`, `adopt.database`) is non-empty
38
+ * and only lowercase letters and digits (it goes into a URL path), and no two
39
+ * entries adopt the same app. `adopt.database` needs an entry with `database`.
40
+ * - `adopt.tunnel` is a Cloudflare tunnel id (a UUID), `adopt.connectorApp` a
41
+ * Coolify uuid that no entry or database adopts. `connectorApp` needs
42
+ * `tunnel`, and both need `hosting.tunnel` (#312).
34
43
  */
35
- export function validateHosting(hosting) {
44
+ export function validateHosting(hosting, names) {
36
45
  if (hosting.tunnel && !hosting.enabled) {
37
46
  return 'hosting.tunnel requires hosting.enabled';
38
47
  }
@@ -60,7 +69,27 @@ export function validateHosting(hosting) {
60
69
  if (hosting.enabled && !appEntry(hosting)) {
61
70
  return `hosting.enabled requires an entry keyed '${APP_ENTRY_KEY}'`;
62
71
  }
72
+ if (hosting.adopt?.database !== undefined && !hosting.adopt.database) {
73
+ return 'hosting.adopt.database needs a non-empty uuid';
74
+ }
75
+ if (hosting.adopt?.database && !ADOPT_UUID.test(hosting.adopt.database)) {
76
+ return 'hosting.adopt.database must be lowercase letters and digits only';
77
+ }
78
+ const { tunnel: adoptedTunnel, connectorApp } = hosting.adopt ?? {};
79
+ if (adoptedTunnel !== undefined && !TUNNEL_ID.test(adoptedTunnel)) {
80
+ return 'hosting.adopt.tunnel must be a Cloudflare tunnel id (a lowercase UUID with hyphens)';
81
+ }
82
+ if (connectorApp !== undefined && !ADOPT_UUID.test(connectorApp)) {
83
+ return 'hosting.adopt.connectorApp must be a non-empty uuid of lowercase letters and digits only';
84
+ }
85
+ if ((connectorApp === undefined) !== (adoptedTunnel === undefined)) {
86
+ return 'hosting.adopt.tunnel and hosting.adopt.connectorApp must be declared together';
87
+ }
88
+ if ((adoptedTunnel !== undefined || connectorApp !== undefined) && !hosting.tunnel) {
89
+ return 'hosting.adopt.tunnel and hosting.adopt.connectorApp require hosting.tunnel';
90
+ }
63
91
  const seenKeys = new Set();
92
+ const seenAdopted = new Set();
64
93
  for (const entry of hosting.entries) {
65
94
  if (seenKeys.has(entry.key)) {
66
95
  return `hosting.entries key '${entry.key}' is not unique`;
@@ -69,12 +98,76 @@ export function validateHosting(hosting) {
69
98
  const invalid = validateHostingEntry(entry, hosting.enabled);
70
99
  if (invalid)
71
100
  return invalid;
101
+ if (entry.adopt) {
102
+ if (!entry.adopt.app)
103
+ return `hosting entry '${entry.key}' adopt.app needs a non-empty uuid`;
104
+ if (!ADOPT_UUID.test(entry.adopt.app)) {
105
+ return `hosting entry '${entry.key}' adopt.app must be lowercase letters and digits only`;
106
+ }
107
+ if (seenAdopted.has(entry.adopt.app)) {
108
+ return `hosting entry '${entry.key}' adopts an app another entry already adopts`;
109
+ }
110
+ seenAdopted.add(entry.adopt.app);
111
+ if (entry.adopt.app === hosting.adopt?.database) {
112
+ return `hosting entry '${entry.key}' adopts the same uuid as hosting.adopt.database`;
113
+ }
114
+ if (entry.database && !hosting.adopt?.database) {
115
+ return `hosting entry '${entry.key}' adopts its app and sets database, so hosting.adopt.database must name the live database`;
116
+ }
117
+ }
118
+ const invalidAuth = validateEntryAuth(entry, hosting, names?.domain);
119
+ if (invalidAuth)
120
+ return invalidAuth;
121
+ }
122
+ if (hosting.adopt?.database && !hosting.entries.some((entry) => entry.database)) {
123
+ return 'hosting.adopt.database is set, so an entry must set database';
72
124
  }
125
+ if (connectorApp && (seenAdopted.has(connectorApp) || connectorApp === hosting.adopt?.database)) {
126
+ return 'hosting.adopt.connectorApp is the uuid of an adopted app or database';
127
+ }
128
+ const invalidNames = validateEntryHostnames(hosting, names);
129
+ if (invalidNames)
130
+ return invalidNames;
73
131
  if (hosting.entries.filter((entry) => entry.database).length > 1) {
74
132
  return 'at most one hosting entry may set database: the one runtime database role cannot serve two apps';
75
133
  }
76
134
  return null;
77
135
  }
136
+ /**
137
+ * The rules for the names of every entry: each is `@`, `*` or a DNS label, no
138
+ * name twice, none on a name foundry owns (`plan/locked-hostnames.ts`), and
139
+ * none at all on a non-`app` entry while `usesDomain` is off. `names` is what
140
+ * the caller knows of the org; without it only the checks that need no domain
141
+ * run.
142
+ */
143
+ function validateEntryHostnames(hosting, names) {
144
+ const taken = new Set();
145
+ for (const entry of hosting.entries) {
146
+ const at = `hosting.entries '${entry.key}'`;
147
+ if (!hosting.usesDomain && entry.key !== APP_ENTRY_KEY && entry.hostnames.length > 0) {
148
+ return `${at} hostnames need hosting.usesDomain: without it only the '${APP_ENTRY_KEY}' entry is served, at its own address`;
149
+ }
150
+ const reads = hosting.usesDomain && names?.domain;
151
+ if (reads && entry.key === APP_ENTRY_KEY && !entry.hostnames.some((n) => n !== '*')) {
152
+ 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`;
153
+ }
154
+ for (const name of entry.hostnames) {
155
+ if (name !== '@' && name !== '*' && !ENTRY_KEY.test(name)) {
156
+ return `${at} hostname '${name}' must be '@', '*' or a DNS label`;
157
+ }
158
+ if (taken.has(name))
159
+ return `hostname '${name}' is held by two entries or listed twice`;
160
+ taken.add(name);
161
+ if (names?.domain &&
162
+ name !== '@' &&
163
+ name !== '*' &&
164
+ names.locked.has(`${name}.${names.domain}`)) {
165
+ return `${at} hostname '${name}' is a name foundry owns: ${name}.${names.domain}`;
166
+ }
167
+ }
168
+ }
169
+ return null;
170
+ }
78
171
  const ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
79
172
  const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
80
173
  // The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
@@ -141,6 +234,46 @@ function validateHostingEntry(entry, enabled) {
141
234
  }
142
235
  return null;
143
236
  }
237
+ /**
238
+ * `uses.auth`: its two switches are known values, sign-up is never open
239
+ * into the staff organisation, the entry has an exact
240
+ * hostname to sign in at (a `*` or an internal entry has none), and the
241
+ * client's name, which is the entry key, is not one another client holds.
242
+ */
243
+ function validateEntryAuth(entry, hosting, domain) {
244
+ const auth = entry.uses?.auth;
245
+ if (!auth)
246
+ return null;
247
+ const at = `hosting.entries '${entry.key}' uses.auth`;
248
+ if (!hosting.enabled)
249
+ return `${at} requires hosting.enabled`;
250
+ if (auth.users !== 'staff' && auth.users !== 'customers') {
251
+ return `${at} users must be 'staff' or 'customers'`;
252
+ }
253
+ if (auth.registration !== 'open' && auth.registration !== 'closed') {
254
+ return `${at} registration must be 'open' or 'closed'`;
255
+ }
256
+ // The staff organisation holds the org's own people and its project.
257
+ if (auth.users === 'staff' && auth.registration === 'open') {
258
+ return `${at} registration 'open' needs users 'customers': nobody signs themselves up as staff`;
259
+ }
260
+ if (auth.replacesBrokerEntry !== undefined &&
261
+ (typeof auth.replacesBrokerEntry !== 'string' || auth.replacesBrokerEntry === '')) {
262
+ return `${at} replacesBrokerEntry must be the non-empty name of a broker entry`;
263
+ }
264
+ // With no domain the hostnames are not read (`entryHostnames`).
265
+ const hasName = hosting.usesDomain && domain !== null
266
+ ? entry.hostnames.some((name) => name !== '*')
267
+ : entry.key === APP_ENTRY_KEY;
268
+ if (!hasName)
269
+ return `${at} needs an exact hostname to sign in at`;
270
+ if (entry.key === 'agora' ||
271
+ entry.key === 'cms' ||
272
+ hosting.issuerApps.some((app) => app.name === entry.key)) {
273
+ return `${at} client name '${entry.key}' is already held by another ZITADEL client`;
274
+ }
275
+ return null;
276
+ }
144
277
  function urlHasPassword(value) {
145
278
  try {
146
279
  return new URL(value).password !== '';
@@ -0,0 +1,112 @@
1
+ import type { DeclaredOrg, HostingEntry } from '../org.js';
2
+ import type { Adoption } from '../store.js';
3
+ /**
4
+ * Adoption (#311): foundry takes over a hand-made Coolify app or database
5
+ * where it is. Nothing in this file holds an env value: an env var is named,
6
+ * never quoted, in a detail line, an error or a blocked item (rule 6).
7
+ */
8
+ interface AdoptedBase {
9
+ /** The `foundry_adoptions.resource_key`, and the `coolify:adopt` step's key. */
10
+ resourceKey: string;
11
+ uuid: string;
12
+ }
13
+ /**
14
+ * One app (with the entry that adopts it) or database an org declares by uuid,
15
+ * or (#312) its Cloudflare tunnel (`uuid` is the tunnel id) or the Coolify app
16
+ * that runs `cloudflared` for it.
17
+ */
18
+ export type AdoptedResource = (AdoptedBase & {
19
+ kind: 'database';
20
+ }) | (AdoptedBase & {
21
+ kind: 'app';
22
+ entry: HostingEntry;
23
+ }) | (AdoptedBase & {
24
+ kind: 'tunnel';
25
+ }) | (AdoptedBase & {
26
+ kind: 'connector';
27
+ });
28
+ /** The `foundry_adoptions.resource_key` of the org's adopted tunnel: also its `serviceIds` key (#312). */
29
+ export declare const adoptedTunnelKey: (org: DeclaredOrg) => string;
30
+ /** The `foundry_adoptions.resource_key` of the org's adopted connector app (#312). */
31
+ export declare const adoptedConnectorKey: (org: DeclaredOrg) => string;
32
+ /** Every resource the org adopts, the database first, then the apps, the tunnel and its connector. */
33
+ export declare function adoptedResources(org: DeclaredOrg): AdoptedResource[];
34
+ /**
35
+ * Every uuid the org declares as adopted, whether or not hosting is enabled
36
+ * now: a uuid in this set is never stopped or deleted by foundry (#311).
37
+ */
38
+ export declare function declaredAdoptUuids(org: DeclaredOrg): Set<string>;
39
+ /**
40
+ * A snapshot is ACTIVE when its row is `adopted` or `confirmed` and the uuid
41
+ * it recorded is the uuid the org declares now (#311). Only then may foundry
42
+ * write to the live resource.
43
+ */
44
+ export declare function isActiveSnapshot(row: Adoption | null | undefined, uuid: string): boolean;
45
+ /**
46
+ * A row in `adopted`, `confirmed` or `restored` protects its key: foundry never
47
+ * manages, stops or deletes that resource as its own. Only `released` lets go (#311).
48
+ */
49
+ export declare function protectsKey(row: Adoption | null | undefined): boolean;
50
+ /** Where the secret part of a snapshot lives in the `AdoptionVault`. */
51
+ export declare function adoptionVaultRef(org: DeclaredOrg, resource: AdoptedResource): string;
52
+ export type LiveApp = Record<string, unknown>;
53
+ export declare function snapshotAppSettings(live: LiveApp): Record<string, unknown>;
54
+ export declare const bareName: (name: string) => string;
55
+ export declare const liveNames: (live: LiveApp) => string[];
56
+ /**
57
+ * What an adopted entry changes on its live app: only a setting it declares
58
+ * that differs from live. The entry cannot tell a declared `branch`,
59
+ * `buildPack` or `autoDeploy` from its default, so those stay as they are; a
60
+ * `null` folder or port and an empty name list declare nothing. Never
61
+ * `custom_network_aliases`.
62
+ */
63
+ export declare function adoptedAppPatch(entry: HostingEntry, names: readonly string[], live: LiveApp): Record<string, unknown>;
64
+ /** One line per setting the patch changes, old value beside new. */
65
+ export declare function adoptedPatchDetails(patch: Record<string, unknown>, live: LiveApp): string[];
66
+ /**
67
+ * The PATCH body that puts an app's snapshot settings back. A field is sent
68
+ * only when the entry's declaration would have changed it from the snapshot
69
+ * (`adoptedAppPatch`), so a setting foundry never touched, or a person edited
70
+ * later, stays. With no entry, a field is sent when `live` differs from the
71
+ * snapshot. Coolify reads `fqdn` but takes `domains`. A field the snapshot had
72
+ * as null is cleared the way the plain app path clears it: a folder back to
73
+ * `/`, domains to the empty list. A port is never cleared, as on that path.
74
+ */
75
+ export declare function restoreAppPatch(entry: HostingEntry | undefined, names: readonly string[], settings: Record<string, unknown>, live: LiveApp): Record<string, unknown>;
76
+ /** One route of a tunnel's ingress list. Other fields (`originRequest`, `path`) ride along untouched. */
77
+ export interface IngressRoute {
78
+ hostname?: string;
79
+ service: string;
80
+ [field: string]: unknown;
81
+ }
82
+ /** A hostname as compared: lower case, no trailing dot (DNS names ignore both). */
83
+ export declare const normalizeHostname: (hostname: string) => string;
84
+ /**
85
+ * What the merge leaves alone: every live route that is not for a declared
86
+ * name and not foundry's own route for a name it dropped. Whatever its shape:
87
+ * a hostname, only a `path`, or neither.
88
+ */
89
+ export declare function foreignRoutes(live: readonly IngressRoute[], declared: readonly string[], service: string, written: ReadonlySet<string>): IngressRoute[];
90
+ /** The declared names whose live routes the merge replaces: there are some, and they are not already foundry's one route. */
91
+ export declare function replacedNames(live: readonly IngressRoute[], declared: readonly string[], service: string): string[];
92
+ /** The names foundry removes from `live`: written, no longer declared, still its own route. */
93
+ export declare const removedNames: (live: readonly IngressRoute[], declared: readonly string[], service: string, written: ReadonlySet<string>) => string[];
94
+ /**
95
+ * The ingress list an adopted tunnel gets (#312): the declared exact names,
96
+ * then every foreign live route in its live order and unchanged, with the
97
+ * snapshot's routes for a name foundry dropped put back among them, then the
98
+ * declared wildcards, then the live catch-all as it is. Only a live list that
99
+ * does not end in a catch-all gets `http_status:404`.
100
+ */
101
+ export declare function mergeIngress(live: readonly IngressRoute[], declared: readonly string[], service: string, written: ReadonlySet<string>, snapshot: readonly IngressRoute[]): IngressRoute[];
102
+ /**
103
+ * The ingress list restore writes (#312): the live list without foundry's own
104
+ * route for a name it wrote (`written` accumulates for the life of the
105
+ * snapshot), plus the snapshot's routes for that name. A name with no live
106
+ * route that is exactly foundry's is left alone, as is a route a person added
107
+ * or edited, and the live catch-all. The `http_status:404` foundry appended is
108
+ * removed only while it is still last and the snapshot had no catch-all; an
109
+ * empty snapshot keeps the live catch-all, or gets one.
110
+ */
111
+ export declare function restoreIngress(live: readonly IngressRoute[], snapshot: readonly IngressRoute[], service: string, written: ReadonlySet<string>): IngressRoute[];
112
+ export {};