@wtfalch/foundry 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/bootstrap/declare.d.ts +2 -2
  2. package/dist/bootstrap/declare.js +6 -6
  3. package/dist/index.d.ts +3 -3
  4. package/dist/index.js +2 -2
  5. package/dist/migrations/0034_owners.sql +42 -0
  6. package/dist/migrations/0035_chat_on_by_default.sql +10 -0
  7. package/dist/org.d.ts +80 -17
  8. package/dist/org.js +221 -18
  9. package/dist/plan/agora.d.ts +19 -1
  10. package/dist/plan/agora.js +37 -2
  11. package/dist/plan/chat-matrix.d.ts +99 -0
  12. package/dist/plan/chat-matrix.js +194 -0
  13. package/dist/plan/diff.d.ts +5 -3
  14. package/dist/plan/diff.js +143 -83
  15. package/dist/plan/entry-ai-key.d.ts +58 -0
  16. package/dist/plan/entry-ai-key.js +209 -0
  17. package/dist/plan/entry-files-key.d.ts +82 -0
  18. package/dist/plan/entry-files-key.js +327 -0
  19. package/dist/plan/executor.d.ts +244 -16
  20. package/dist/plan/executor.js +866 -84
  21. package/dist/plan/hostnames.d.ts +14 -1
  22. package/dist/plan/hostnames.js +26 -1
  23. package/dist/plan/index.d.ts +1 -0
  24. package/dist/plan/index.js +1 -0
  25. package/dist/plan/plan-apply.d.ts +2 -0
  26. package/dist/plan/plan-apply.js +42 -2
  27. package/dist/plan/platform-org.d.ts +17 -5
  28. package/dist/plan/platform-org.js +15 -7
  29. package/dist/plan/platform-providers.js +4 -3
  30. package/dist/plan/proxy-labels.d.ts +42 -0
  31. package/dist/plan/proxy-labels.js +73 -0
  32. package/dist/plan/zitadel-machine-user.d.ts +5 -1
  33. package/dist/plan/zitadel-machine-user.js +5 -1
  34. package/dist/plan/zitadel-project-roles.d.ts +25 -0
  35. package/dist/plan/zitadel-project-roles.js +104 -0
  36. package/dist/store/postgres.js +4 -17
  37. package/dist/store/schema.d.ts +13 -45
  38. package/dist/store/schema.js +5 -8
  39. package/package.json +1 -1
@@ -27,8 +27,8 @@ export interface BootstrapOwner {
27
27
  */
28
28
  export declare function ownerIdentity(owner: BootstrapOwner): OwnerIdentity;
29
29
  /**
30
- * Company Included's declared org: the platform's owner, running no app, mail
31
- * or chat of its own.
30
+ * Company Included's declared org: the platform's owner, running no app or
31
+ * mail of its own. Chat is on, like every org's.
32
32
  */
33
33
  export declare function declaredCompanyOrg(company: BootstrapCompany, owner: BootstrapOwner): DeclaredOrg;
34
34
  /**
@@ -1,4 +1,4 @@
1
- import { validateOwner } from '../org.js';
1
+ import { validateOwners } from '../org.js';
2
2
  import { PLATFORM_SERVICE_KEYS, defaultPlatformHostname } from '../platform.js';
3
3
  import { VERSIONS } from './versions.js';
4
4
  /** Company Included's own org id, the same literal `test/fakes.ts`'s `basePlatform()` uses for `ownerOrgId`. */
@@ -37,14 +37,14 @@ export function ownerIdentity(owner) {
37
37
  givenName: owner.givenName?.trim() ?? '',
38
38
  familyName: owner.familyName?.trim() ?? '',
39
39
  };
40
- const problem = validateOwner(identity);
40
+ const problem = validateOwners([identity]);
41
41
  if (problem)
42
42
  throw new Error(`bootstrapPlatform: ${problem}`);
43
43
  return identity;
44
44
  }
45
45
  /**
46
- * Company Included's declared org: the platform's owner, running no app, mail
47
- * or chat of its own.
46
+ * Company Included's declared org: the platform's owner, running no app or
47
+ * mail of its own. Chat is on, like every org's.
48
48
  */
49
49
  export function declaredCompanyOrg(company, owner) {
50
50
  return {
@@ -58,7 +58,7 @@ export function declaredCompanyOrg(company, owner) {
58
58
  ownerMailboxAddress: null,
59
59
  noReplyLocalPart: 'no-reply',
60
60
  },
61
- chat: { enabled: false, usesDomain: false },
61
+ chat: { enabled: true, usesDomain: false },
62
62
  calls: { enabled: false, usesDomain: false },
63
63
  issuer: { usesDomain: false },
64
64
  site: { enabled: false, usesDomain: false },
@@ -77,7 +77,7 @@ export function declaredCompanyOrg(company, owner) {
77
77
  buyer: { legalName: null, orgNumber: null, address: null, vatRegistered: null },
78
78
  bespokeAppRepo: null,
79
79
  adoptedApp: null,
80
- owner: ownerIdentity(owner),
80
+ owners: [ownerIdentity(owner)],
81
81
  test: false,
82
82
  };
83
83
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
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';
1
+ export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, } from './org.js';
2
+ export { APP_ENTRY_KEY, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, appEntry, ownerMailboxes, validateBuyerIdentity, validateHosting, validateMail, validateOwners, 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';
@@ -27,7 +27,7 @@ export type { MailIdentityApp, MailIdentityAudienceCheck } from './plan/index.js
27
27
  export { checkMailIdentityAudience } from './plan/index.js';
28
28
  export type { MailAllowlistApp } from './plan/index.js';
29
29
  export { checkMailAppsAllowlist } from './plan/index.js';
30
- export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
30
+ export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelProjectRoles, 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
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';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, appEntry, validateBuyerIdentity, validateHosting, validateMail, validateOwner, validateSite, } from './org.js';
1
+ export { APP_ENTRY_KEY, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, appEntry, ownerMailboxes, validateBuyerIdentity, validateHosting, validateMail, validateOwners, 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';
@@ -12,7 +12,7 @@ export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from
12
12
  export { HttpJobsSchedulerClient } from './plan/index.js';
13
13
  export { checkMailIdentityAudience } from './plan/index.js';
14
14
  export { checkMailAppsAllowlist } from './plan/index.js';
15
- export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
15
+ export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
16
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';
@@ -0,0 +1,42 @@
1
+ -- @wtfalch/foundry: an org holds a list of current owners instead of one.
2
+ -- Mirrors `src/store/schema.ts`'s `declaredOrgs.owners`.
3
+ --
4
+ -- Storage is one jsonb array on `foundry_declared_orgs`, the way
5
+ -- `hosting_entries` (0029) holds its list: owners are an ordered list read
6
+ -- and written whole with the org, never joined or queried by owner, and the
7
+ -- first one is the primary. A table would add a join and an ordering column
8
+ -- for nothing. Past owners are not kept.
9
+ --
10
+ -- Moves the 0010 columns (`owner_email`, `owner_given_name`,
11
+ -- `owner_family_name`) into a one-element array for each org that has all
12
+ -- three, and drops them. Numbered 0034 so it cannot collide with a 0033 that
13
+ -- lands first. Safe to run twice: the backfill only touches rows still
14
+ -- holding the old columns, and every drop is `if exists`. Run it in the same
15
+ -- deploy as this version.
16
+
17
+ alter table foundry_declared_orgs
18
+ add column if not exists owners jsonb not null default '[]'::jsonb;
19
+
20
+ do $$
21
+ begin
22
+ if exists (
23
+ select 1 from information_schema.columns
24
+ where table_schema = current_schema()
25
+ and table_name = 'foundry_declared_orgs'
26
+ and column_name = 'owner_email'
27
+ ) then
28
+ update foundry_declared_orgs
29
+ set owners = jsonb_build_array(jsonb_build_object(
30
+ 'email', owner_email,
31
+ 'givenName', owner_given_name,
32
+ 'familyName', owner_family_name))
33
+ where owner_email is not null
34
+ and owner_given_name is not null
35
+ and owner_family_name is not null;
36
+ end if;
37
+ end $$;
38
+
39
+ alter table foundry_declared_orgs
40
+ drop column if exists owner_email,
41
+ drop column if exists owner_given_name,
42
+ drop column if exists owner_family_name;
@@ -0,0 +1,10 @@
1
+ -- @wtfalch/foundry: chat is on by default for a new org. Mirrors
2
+ -- `src/store/schema.ts`'s `chatEnabled`. Changes the column default only, so
3
+ -- an org row that exists keeps the value it has: every org is provisioned
4
+ -- fresh, so nothing is backfilled and no live org is switched on by this
5
+ -- migration.
6
+ --
7
+ -- Safe to run twice.
8
+
9
+ alter table foundry_declared_orgs
10
+ alter column chat_enabled set default true;
package/dist/org.d.ts CHANGED
@@ -100,9 +100,10 @@ export declare const DEFAULT_NO_REPLY_LOCAL_PART = "no-reply";
100
100
  */
101
101
  export interface ChatConfig {
102
102
  /**
103
- * Chat is not in v1 (decided 2026-10-01): off by default for every org.
104
- * While off, `apply` plans no chat step and leaves a live chat resource
105
- * alone, listed in `unmanaged`; Agora hides all chat and call UI.
103
+ * On by default for a new org (migration 0035; Decided 2026-10-03: every
104
+ * company gets working chat before go-live). While off, `apply` plans no
105
+ * chat step and leaves a live chat resource alone, listed in `unmanaged`;
106
+ * Agora hides all chat UI.
106
107
  */
107
108
  enabled: boolean;
108
109
  usesDomain: boolean;
@@ -272,8 +273,44 @@ export interface EntryAuth {
272
273
  registration: 'open' | 'closed';
273
274
  replacesBrokerEntry?: string;
274
275
  }
276
+ /**
277
+ * AI for one entry: a key below the org's own ai key, the `AI_*` env and,
278
+ * with `webhookPath`, the ai webhook. `offerings` is what the app may call,
279
+ * delivered as `AI_OFFERINGS_JSON`. `permissions` is a subset of
280
+ * {@link AI_ENTRY_PERMISSIONS}, which is also the default. ai keeps one
281
+ * webhook per organisation, so one entry per org may set `webhookPath`.
282
+ */
283
+ export interface EntryAi {
284
+ offerings: string[];
285
+ permissions?: string[];
286
+ webhookPath?: string;
287
+ }
288
+ /** What an entry's ai key may hold: the most the org's own key holds. */
289
+ export declare const AI_ENTRY_PERMISSIONS: readonly ["ai.offerings:invoke", "ai.runs:create"];
290
+ /** The env names `uses.ai` writes; `env` may not name them. */
291
+ export declare const AI_ENTRY_ENV_NAMES: readonly ["AI_URL", "AI_ORGANISATION_ID", "AI_API_KEY", "AI_WEBHOOK_SECRET", "AI_OFFERINGS_JSON"];
292
+ /**
293
+ * Files for one entry: a key below the org's own files key, the `FILES_*`
294
+ * env and one collection per name in `collections`. A name is uppercase
295
+ * `[A-Z0-9_]`; its id is delivered as `FILES_COLLECTION_<NAME>_ID`.
296
+ * `permissions` is a subset of {@link FILES_ENTRY_PERMISSIONS}, which is also
297
+ * the default. `shareHost` asks for `share.<domain>`; it is reported blocked
298
+ * until the platform files host serves that name.
299
+ */
300
+ export interface EntryFiles {
301
+ collections: string[];
302
+ permissions?: string[];
303
+ shareHost?: boolean;
304
+ }
305
+ /** What an entry's files key may hold: read and write on its collections, and the most the org's own key holds besides minting. */
306
+ export declare const FILES_ENTRY_PERMISSIONS: readonly ["files.collections:read", "files.files:read", "files.files:download", "files.files:upload", "files.files:replace", "files.files:delete"];
307
+ /** The env names `uses.files` always writes, besides one `FILES_COLLECTION_<NAME>_ID` per collection. */
308
+ export declare const FILES_ENTRY_ENV_NAMES: readonly ["FILES_URL", "FILES_ORGANISATION_ID", "FILES_API_KEY"];
309
+ export declare const filesCollectionEnvName: (name: string) => string;
275
310
  export interface HostingEntryUses {
276
311
  auth?: EntryAuth;
312
+ ai?: EntryAi;
313
+ files?: EntryFiles;
277
314
  }
278
315
  /**
279
316
  * A plain string is pushed as it is, so `validateHosting` refuses one that
@@ -439,28 +476,52 @@ export interface BuyerIdentity {
439
476
  */
440
477
  export declare function validateBuyerIdentity(buyer: BuyerIdentity): string | null;
441
478
  /**
442
- * The org's owner: the one person a real org is created for, invited into
443
- * its ZITADEL org by email so they can set their own first credential (a
444
- * passkey) without a password ever passing through foundry. `null` until an
445
- * operator names one -- an org can exist declared before its owner is known.
479
+ * One of the org's current owners: a person a real org is created for,
480
+ * invited into its ZITADEL org by email so they can set their own first
481
+ * credential (a passkey) without a password ever passing through foundry.
482
+ * An org holds a list of them (`DeclaredOrg.owners`), empty until an operator
483
+ * names one -- an org can exist declared before its owners are known. Past
484
+ * owners are not kept: removing one from the list forgets them.
446
485
  *
447
- * `apply`'s `zitadel:owner` step (`plan/diff.ts`'s `planOwner`) calls
486
+ * `apply`'s `zitadel:owner` steps (`plan/diff.ts`'s `planOwner`) call
448
487
  * `ZitadelClient.inviteOrgOwner(zitadelOrgId, owner)` once the org's ZITADEL
449
- * org exists, then never touches this again -- a re-run is idempotent on the
450
- * recorded invited user id, not on this declared value, so editing `owner`
488
+ * org exists, then never touch it again -- a re-run is idempotent on the
489
+ * recorded invited user id, not on this declared value, so editing an owner
451
490
  * after the invite has gone out does not send a second one.
491
+ *
492
+ * `mailboxAddress` is the owner's own mailbox, when mail is on. The first
493
+ * owner falls back to `mail.ownerMailboxAddress` when it is unset.
452
494
  */
453
495
  export interface OwnerIdentity {
454
496
  email: string;
455
497
  givenName: string;
456
498
  familyName: string;
499
+ mailboxAddress?: string;
457
500
  }
458
501
  /**
459
- * `null` when `owner` is valid; otherwise the reason it is not, same shape as
460
- * {@link validateBuyerIdentity} and `../slug.ts`'s `validateSlug`. `owner:
461
- * null` (no owner named yet) is always valid.
502
+ * `null` when `owners` is valid; otherwise the reason it is not, same shape as
503
+ * {@link validateBuyerIdentity} and `../slug.ts`'s `validateSlug`. An empty
504
+ * list (no owner named yet) is always valid. Each owner needs a valid email
505
+ * and non-empty names; emails are distinct (compared case-insensitively).
462
506
  */
463
- export declare function validateOwner(owner: OwnerIdentity | null): string | null;
507
+ export declare function validateOwners(owners: readonly OwnerIdentity[]): string | null;
508
+ /**
509
+ * One owner's mailbox to give: the owner's own `mailboxAddress`, or for the
510
+ * first owner `mail.ownerMailboxAddress`. `address` is `null` when none is
511
+ * declared. `scopeKey` is keyed by the owner's email, never by position.
512
+ */
513
+ export interface OwnerMailbox {
514
+ owner: OwnerIdentity;
515
+ address: string | null;
516
+ scopeKey: string;
517
+ }
518
+ export declare function ownerMailboxes(org: Pick<DeclaredOrg, 'owners' | 'mail'>): OwnerMailbox[];
519
+ /**
520
+ * True when this mailbox is already made: its own key is recorded, or another
521
+ * owner's key records the same address (the `mail.ownerMailboxAddress` fallback
522
+ * moves to a new first owner on reorder; the mail port refuses a duplicate).
523
+ */
524
+ export declare function ownerMailboxRecorded(box: OwnerMailbox, recordedIds: Readonly<Record<string, string>>): boolean;
464
525
  /**
465
526
  * `null` when `mail` is valid; otherwise the reason it is not (#162,
466
527
  * decided 2026-09-29).
@@ -491,7 +552,9 @@ export declare function validateOwner(owner: OwnerIdentity | null): string | nul
491
552
  * the same reason: a duplicate or blank entry can never mean anything
492
553
  * different from leaving it out.
493
554
  */
494
- export declare function validateMail(org: Pick<DeclaredOrg, 'mail' | 'domain'>): string | null;
555
+ export declare function validateMail(org: Pick<DeclaredOrg, 'mail' | 'domain'> & {
556
+ owners?: readonly OwnerIdentity[];
557
+ }): string | null;
495
558
  /**
496
559
  * The form an app foundry does not own takes. Decided in #7's `#15`
497
560
  * refinement comment:
@@ -577,8 +640,8 @@ export interface DeclaredOrg {
577
640
  bespokeAppRepo: string | null;
578
641
  /** `null` when the org has none. */
579
642
  adoptedApp: AdoptedApp | null;
580
- /** The org's owner, invited by ZITADEL email. `null` until an operator names one. See {@link OwnerIdentity}. */
581
- owner: OwnerIdentity | null;
643
+ /** The org's current owners, each invited by ZITADEL email. Empty until an operator names one; the first is the one `OWNER_USER_ID` names. See {@link OwnerIdentity}. */
644
+ owners: OwnerIdentity[];
582
645
  /**
583
646
  * Marks this org as a test org rather than a real customer's. Purely
584
647
  * declarative -- it changes nothing about what `apply` provisions -- but it
package/dist/org.js CHANGED
@@ -4,6 +4,32 @@ export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
4
4
  export const DEFAULT_NO_REPLY_LOCAL_PART = 'no-reply';
5
5
  /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
6
6
  export const APP_ENTRY_KEY = 'app';
7
+ /** What an entry's ai key may hold: the most the org's own key holds. */
8
+ export const AI_ENTRY_PERMISSIONS = ['ai.offerings:invoke', 'ai.runs:create'];
9
+ /** The env names `uses.ai` writes; `env` may not name them. */
10
+ export const AI_ENTRY_ENV_NAMES = [
11
+ 'AI_URL',
12
+ 'AI_ORGANISATION_ID',
13
+ 'AI_API_KEY',
14
+ 'AI_WEBHOOK_SECRET',
15
+ 'AI_OFFERINGS_JSON',
16
+ ];
17
+ /** What an entry's files key may hold: read and write on its collections, and the most the org's own key holds besides minting. */
18
+ export const FILES_ENTRY_PERMISSIONS = [
19
+ 'files.collections:read',
20
+ 'files.files:read',
21
+ 'files.files:download',
22
+ 'files.files:upload',
23
+ 'files.files:replace',
24
+ 'files.files:delete',
25
+ ];
26
+ /** The env names `uses.files` always writes, besides one `FILES_COLLECTION_<NAME>_ID` per collection. */
27
+ export const FILES_ENTRY_ENV_NAMES = [
28
+ 'FILES_URL',
29
+ 'FILES_ORGANISATION_ID',
30
+ 'FILES_API_KEY',
31
+ ];
32
+ export const filesCollectionEnvName = (name) => `FILES_COLLECTION_${name}_ID`;
7
33
  /** The org's `app` entry: today's one app. */
8
34
  export function appEntry(hosting) {
9
35
  return hosting.entries.find((entry) => entry.key === APP_ENTRY_KEY);
@@ -72,6 +98,15 @@ export function validateHosting(hosting, names) {
72
98
  const invalidAuth = validateEntryAuth(entry, hosting, names?.domain);
73
99
  if (invalidAuth)
74
100
  return invalidAuth;
101
+ const invalidAi = validateEntryAi(entry, hosting, names?.domain);
102
+ if (invalidAi)
103
+ return invalidAi;
104
+ const invalidFiles = validateEntryFiles(entry, hosting, names?.domain);
105
+ if (invalidFiles)
106
+ return invalidFiles;
107
+ }
108
+ if (hosting.entries.filter((entry) => entry.uses?.ai?.webhookPath !== undefined).length > 1) {
109
+ return 'at most one hosting entry may set uses.ai webhookPath: ai keeps one webhook per organisation';
75
110
  }
76
111
  const invalidNames = validateEntryHostnames(hosting, names);
77
112
  if (invalidNames)
@@ -92,6 +127,14 @@ function validateEntryHostnames(hosting, names) {
92
127
  const taken = new Set();
93
128
  for (const entry of hosting.entries) {
94
129
  const at = `hosting.entries '${entry.key}'`;
130
+ if (entry.hostnames.includes('*')) {
131
+ if (!hosting.usesDomain || (names && !names.domain)) {
132
+ return `${at} hostname '*' needs hosting.usesDomain and an org domain: it serves any name under the domain`;
133
+ }
134
+ if (hosting.entries.filter((e) => e.hostnames.includes('*')).length > 1) {
135
+ return `hostname '*' is held by two entries: an org has one wildcard`;
136
+ }
137
+ }
95
138
  if (!hosting.usesDomain && entry.key !== APP_ENTRY_KEY && entry.hostnames.length > 0) {
96
139
  return `${at} hostnames need hosting.usesDomain: without it only the '${APP_ENTRY_KEY}' entry is served, at its own address`;
97
140
  }
@@ -209,12 +252,9 @@ function validateEntryAuth(entry, hosting, domain) {
209
252
  (typeof auth.replacesBrokerEntry !== 'string' || auth.replacesBrokerEntry === '')) {
210
253
  return `${at} replacesBrokerEntry must be the non-empty name of a broker entry`;
211
254
  }
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)
255
+ if (!hasExactHostname(entry, hosting, domain)) {
217
256
  return `${at} needs an exact hostname to sign in at`;
257
+ }
218
258
  if (entry.key === 'agora' ||
219
259
  entry.key === 'cms' ||
220
260
  hosting.issuerApps.some((app) => app.name === entry.key)) {
@@ -222,6 +262,89 @@ function validateEntryAuth(entry, hosting, domain) {
222
262
  }
223
263
  return null;
224
264
  }
265
+ /** With no domain the hostnames are not read (`entryHostnames`). */
266
+ function hasExactHostname(entry, hosting, domain) {
267
+ return hosting.usesDomain && domain !== null
268
+ ? entry.hostnames.some((name) => name !== '*')
269
+ : entry.key === APP_ENTRY_KEY;
270
+ }
271
+ /**
272
+ * `uses.ai`: at least one offering, permissions from the known set, a
273
+ * webhook path that is a plain path on an exact hostname, and no `env` entry
274
+ * that names a value `uses.ai` writes.
275
+ */
276
+ function validateEntryAi(entry, hosting, domain) {
277
+ const ai = entry.uses?.ai;
278
+ if (!ai)
279
+ return null;
280
+ const at = `hosting.entries '${entry.key}' uses.ai`;
281
+ if (!hosting.enabled)
282
+ return `${at} requires hosting.enabled`;
283
+ const isNames = (value) => Array.isArray(value) &&
284
+ value.length > 0 &&
285
+ value.every((v) => typeof v === 'string' && v !== '') &&
286
+ new Set(value).size === value.length;
287
+ if (!isNames(ai.offerings))
288
+ return `${at} offerings must be a list of distinct, non-empty names`;
289
+ if (ai.permissions !== undefined) {
290
+ const known = AI_ENTRY_PERMISSIONS;
291
+ if (!isNames(ai.permissions) || !ai.permissions.every((p) => known.includes(p))) {
292
+ return `${at} permissions must be distinct values of: ${known.join(', ')}`;
293
+ }
294
+ }
295
+ if (ai.webhookPath !== undefined) {
296
+ if (typeof ai.webhookPath !== 'string' ||
297
+ !/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{0,199}$/.test(ai.webhookPath) ||
298
+ ai.webhookPath.startsWith('//')) {
299
+ return `${at} webhookPath must be a path that starts with one '/', with no query or fragment`;
300
+ }
301
+ if (!hasExactHostname(entry, hosting, domain)) {
302
+ return `${at} webhookPath needs an exact hostname to receive the webhook at`;
303
+ }
304
+ }
305
+ const named = Object.keys(entry.env ?? {}).find((name) => AI_ENTRY_ENV_NAMES.includes(name));
306
+ if (named)
307
+ return `${at} writes ${named}; remove it from env`;
308
+ return null;
309
+ }
310
+ /**
311
+ * `uses.files`: distinct collection names of uppercase letters, digits and
312
+ * `_`, permissions from the known set, `shareHost` only with a domain, and no
313
+ * `env` entry that names a value `uses.files` writes.
314
+ */
315
+ function validateEntryFiles(entry, hosting, domain) {
316
+ const files = entry.uses?.files;
317
+ if (!files)
318
+ return null;
319
+ const at = `hosting.entries '${entry.key}' uses.files`;
320
+ if (!hosting.enabled)
321
+ return `${at} requires hosting.enabled`;
322
+ const names = (value) => Array.isArray(value) &&
323
+ value.every((v) => typeof v === 'string' && v !== '') &&
324
+ new Set(value).size === value.length;
325
+ if (!names(files.collections) || !files.collections.every((n) => /^[A-Z][A-Z0-9_]*$/.test(n))) {
326
+ return `${at} collections must be distinct names of uppercase letters, digits and '_', starting with a letter`;
327
+ }
328
+ if (files.permissions !== undefined) {
329
+ const known = FILES_ENTRY_PERMISSIONS;
330
+ if (!names(files.permissions) ||
331
+ files.permissions.length === 0 ||
332
+ !files.permissions.every((p) => known.includes(p))) {
333
+ return `${at} permissions must be distinct values of: ${known.join(', ')}`;
334
+ }
335
+ }
336
+ if (files.shareHost !== undefined && typeof files.shareHost !== 'boolean') {
337
+ return `${at} shareHost must be true or false`;
338
+ }
339
+ if (files.shareHost && !(hosting.usesDomain && domain)) {
340
+ return `${at} shareHost needs hosting.usesDomain and a domain: it is served at share.<domain>`;
341
+ }
342
+ const written = [...FILES_ENTRY_ENV_NAMES, ...files.collections.map(filesCollectionEnvName)];
343
+ const named = Object.keys(entry.env ?? {}).find((name) => written.includes(name));
344
+ if (named)
345
+ return `${at} writes ${named}; remove it from env`;
346
+ return null;
347
+ }
225
348
  function urlHasPassword(value) {
226
349
  try {
227
350
  return new URL(value).password !== '';
@@ -254,7 +377,7 @@ function urlHasPassword(value) {
254
377
  * case both exist to be compared in the first place.
255
378
  *
256
379
  * `null` when `domain` is valid; otherwise the reason it is not, same shape
257
- * as {@link validateBuyerIdentity}/{@link validateOwner}.
380
+ * as {@link validateBuyerIdentity}/{@link validateOwners}.
258
381
  */
259
382
  const DOMAIN_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
260
383
  export function validateDomain(domain) {
@@ -339,24 +462,101 @@ export function validateBuyerIdentity(buyer) {
339
462
  /** A deliberately loose email shape check -- foundry never sends mail itself, ZITADEL does; this only catches an obviously malformed address before it reaches the invite call. */
340
463
  const OWNER_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
341
464
  /**
342
- * `null` when `owner` is valid; otherwise the reason it is not, same shape as
343
- * {@link validateBuyerIdentity} and `../slug.ts`'s `validateSlug`. `owner:
344
- * null` (no owner named yet) is always valid.
465
+ * `null` when `owners` is valid; otherwise the reason it is not, same shape as
466
+ * {@link validateBuyerIdentity} and `../slug.ts`'s `validateSlug`. An empty
467
+ * list (no owner named yet) is always valid. Each owner needs a valid email
468
+ * and non-empty names; emails are distinct (compared case-insensitively).
345
469
  */
346
- export function validateOwner(owner) {
347
- if (owner === null)
348
- return null;
349
- if (!OWNER_EMAIL_PATTERN.test(owner.email)) {
350
- return `owner email '${owner.email}' is not a valid email address`;
470
+ export function validateOwners(owners) {
471
+ const seen = new Set();
472
+ const boxes = new Set();
473
+ for (const owner of owners) {
474
+ if (!OWNER_EMAIL_PATTERN.test(owner.email)) {
475
+ return `owner email '${owner.email}' is not a valid email address`;
476
+ }
477
+ if (!owner.givenName.trim())
478
+ return `owner ${owner.email} givenName must not be empty`;
479
+ if (!owner.familyName.trim())
480
+ return `owner ${owner.email} familyName must not be empty`;
481
+ if (owner.mailboxAddress !== undefined && !OWNER_EMAIL_PATTERN.test(owner.mailboxAddress)) {
482
+ return `owner ${owner.email} mailboxAddress '${owner.mailboxAddress}' is not a valid email address`;
483
+ }
484
+ if (owner.mailboxAddress !== undefined) {
485
+ const box = owner.mailboxAddress.toLowerCase();
486
+ if (boxes.has(box))
487
+ return `owner mailboxAddress '${owner.mailboxAddress}' is listed more than once`;
488
+ boxes.add(box);
489
+ }
490
+ const key = owner.email.toLowerCase();
491
+ if (seen.has(key))
492
+ return `owner email '${owner.email}' is listed more than once`;
493
+ seen.add(key);
351
494
  }
352
- if (!owner.givenName.trim())
353
- return 'owner.givenName must not be empty';
354
- if (!owner.familyName.trim())
355
- return 'owner.familyName must not be empty';
356
495
  return null;
357
496
  }
497
+ /**
498
+ * Each owner's mailbox is recorded under `mail_owner_mailbox.<lowercased email>`, the
499
+ * same key as the owner's sign-in, so reordering owners moves no mailbox. Removing an
500
+ * owner revokes nothing: their ZITADEL user, mailbox and recorded keys stay (they can
501
+ * still sign in), and adding the same email back is skipped as already done. Shared by
502
+ * `plan/diff.ts` and `plan/executor.ts` through {@link ownerMailboxes}.
503
+ */
504
+ const OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
505
+ export function ownerMailboxes(org) {
506
+ return org.owners.map((owner, index) => ({
507
+ owner,
508
+ address: owner.mailboxAddress ?? (index === 0 ? (org.mail.ownerMailboxAddress ?? null) : null),
509
+ scopeKey: `${OWNER_MAILBOX_SCOPE_KEY}.${owner.email.toLowerCase()}`,
510
+ }));
511
+ }
512
+ /**
513
+ * True when this mailbox is already made: its own key is recorded, or another
514
+ * owner's key records the same address (the `mail.ownerMailboxAddress` fallback
515
+ * moves to a new first owner on reorder; the mail port refuses a duplicate).
516
+ */
517
+ export function ownerMailboxRecorded(box, recordedIds) {
518
+ if (recordedIds[box.scopeKey])
519
+ return true;
520
+ const address = box.address?.toLowerCase();
521
+ return (address !== undefined &&
522
+ Object.entries(recordedIds).some(([key, value]) => key.startsWith(`${OWNER_MAILBOX_SCOPE_KEY}.`) && value.toLowerCase() === address));
523
+ }
358
524
  /** A local part has no `@` and no whitespace -- loose on purpose, the same restraint {@link OWNER_EMAIL_PATTERN}'s own doc comment states. */
359
525
  const LOCAL_PART_PATTERN = /^[^\s@]+$/;
526
+ /**
527
+ * Each owner's own `mailboxAddress` is on the org's domain (when mail uses it, as
528
+ * `mail.ownerMailboxAddress` must be), and is not `mail.ownerMailboxAddress`, a
529
+ * role mailbox or the no-reply address. Addresses compare case-insensitively.
530
+ * Owner-vs-owner duplicates are {@link validateOwners}' job.
531
+ */
532
+ function validateOwnerMailboxes(org) {
533
+ const { mail } = org;
534
+ const localPart = (a) => a.slice(0, a.indexOf('@')).toLowerCase();
535
+ const roles = new Set((mail.roleMailboxes ?? []).map((r) => r.toLowerCase()));
536
+ const noReply = (mail.noReplyLocalPart ?? DEFAULT_NO_REPLY_LOCAL_PART).toLowerCase();
537
+ for (const owner of org.owners ?? []) {
538
+ const address = owner.mailboxAddress;
539
+ if (address === undefined || !OWNER_EMAIL_PATTERN.test(address))
540
+ continue;
541
+ if (mail.usesDomain && org.domain) {
542
+ const addressDomain = address.slice(address.indexOf('@') + 1).toLowerCase();
543
+ if (addressDomain !== org.domain.toLowerCase()) {
544
+ return `owner ${owner.email} mailboxAddress '${address}' must be on ${org.domain}`;
545
+ }
546
+ }
547
+ if (mail.ownerMailboxAddress &&
548
+ address.toLowerCase() === mail.ownerMailboxAddress.toLowerCase()) {
549
+ return `owner ${owner.email} mailboxAddress '${address}' clashes with mail.ownerMailboxAddress`;
550
+ }
551
+ if (roles.has(localPart(address))) {
552
+ return `owner ${owner.email} mailboxAddress '${address}' clashes with a role mailbox`;
553
+ }
554
+ if (localPart(address) === noReply) {
555
+ return `owner ${owner.email} mailboxAddress '${address}' clashes with the no-reply mailbox`;
556
+ }
557
+ }
558
+ return null;
559
+ }
360
560
  /**
361
561
  * `null` when `mail` is valid; otherwise the reason it is not (#162,
362
562
  * decided 2026-09-29).
@@ -425,6 +625,9 @@ export function validateMail(org) {
425
625
  seen.add(name.toLowerCase());
426
626
  }
427
627
  }
628
+ const ownerProblem = validateOwnerMailboxes(org);
629
+ if (ownerProblem)
630
+ return ownerProblem;
428
631
  if (mail.mailApps) {
429
632
  const seen = new Set();
430
633
  for (const name of mail.mailApps) {
@@ -12,6 +12,24 @@ import type { DeclaredOrg, OrgId } from '../org.js';
12
12
  * (recorded by `executor.ts`'s `zitadelApp`).
13
13
  */
14
14
  export declare const ZITADEL_ORG_SCOPE_KEY = "zitadel_org";
15
+ /**
16
+ * The org's ZITADEL messaging project: the audience chat-service requires in
17
+ * a token's `aud`, and Agora's `messagingProjectId`. `executor.ts`'s
18
+ * `coolifyChat` finds or creates it (`<slug>-messaging`) and records its id
19
+ * here; a host value for the same id wins when it supplies one.
20
+ */
21
+ export declare const ZITADEL_MESSAGING_PROJECT_SCOPE_KEY = "zitadel_messaging_project";
22
+ /**
23
+ * Recorded by `coolifyChat` last, once chat's three apps carry their fixed
24
+ * internal names: the chat service's address. `desiredAgoraEnv` waits for it
25
+ * (equal to the address the current slug gives), so Agora is never handed an
26
+ * address that does not resolve yet.
27
+ */
28
+ export declare const CHAT_SERVICE_URL_SCOPE_KEY = "coolify_chat_service_url";
29
+ /** Whether the recorded marker says chat's apps carry the names the org's current slug gives them. */
30
+ export declare function chatHasUpstreams(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): boolean;
31
+ /** `host` with the recorded messaging project filled in when the host supplies none. */
32
+ export declare function withRecordedMessagingProject(host: AgoraHostValues, recorded: Readonly<Record<string, string>>): AgoraHostValues;
15
33
  export declare const AGORA_CLIENT_ID_SCOPE_KEY = "agora_client_id";
16
34
  /**
17
35
  * The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
@@ -159,7 +177,7 @@ export declare function callsServiceHasTokenEnv(host: CallsTokenHost | null, rec
159
177
  * not `host`: present only when calls are on and this org's calls service
160
178
  * app carries its internal name ({@link callsServiceHasName}).
161
179
  */
162
- export declare function desiredAgoraEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, host: AgoraHostValues): {
180
+ export declare function desiredAgoraEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, rawHost: AgoraHostValues): {
163
181
  env: Record<string, string>;
164
182
  missing: string[];
165
183
  };