@wtfalch/foundry 0.14.0 → 0.16.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.d.ts +2 -2
- package/dist/bootstrap/declare.js +6 -6
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/migrations/0034_owners.sql +42 -0
- package/dist/migrations/0035_chat_on_by_default.sql +10 -0
- package/dist/org.d.ts +80 -17
- package/dist/org.js +221 -18
- package/dist/plan/agora.d.ts +19 -1
- package/dist/plan/agora.js +37 -2
- package/dist/plan/chat-matrix.d.ts +99 -0
- package/dist/plan/chat-matrix.js +194 -0
- package/dist/plan/diff.d.ts +5 -3
- package/dist/plan/diff.js +143 -83
- package/dist/plan/entry-ai-key.d.ts +58 -0
- package/dist/plan/entry-ai-key.js +209 -0
- package/dist/plan/entry-files-key.d.ts +82 -0
- package/dist/plan/entry-files-key.js +327 -0
- package/dist/plan/executor.d.ts +244 -16
- package/dist/plan/executor.js +860 -78
- package/dist/plan/hostnames.d.ts +8 -1
- package/dist/plan/hostnames.js +18 -1
- package/dist/plan/index.d.ts +2 -1
- package/dist/plan/index.js +2 -1
- package/dist/plan/plan-apply.d.ts +2 -0
- package/dist/plan/plan-apply.js +42 -2
- package/dist/plan/platform-diff.d.ts +8 -0
- package/dist/plan/platform-diff.js +8 -0
- package/dist/plan/platform-org.d.ts +17 -5
- package/dist/plan/platform-org.js +15 -7
- package/dist/plan/platform-providers.d.ts +38 -12
- package/dist/plan/platform-providers.js +99 -16
- package/dist/plan/proxy-labels.d.ts +42 -0
- package/dist/plan/proxy-labels.js +73 -0
- package/dist/plan/zitadel-machine-user.d.ts +5 -1
- package/dist/plan/zitadel-machine-user.js +5 -1
- package/dist/plan/zitadel-project-roles.d.ts +25 -0
- package/dist/plan/zitadel-project-roles.js +104 -0
- package/dist/store/postgres.js +4 -17
- package/dist/store/schema.d.ts +13 -45
- package/dist/store/schema.js +5 -8
- package/package.json +2 -2
|
@@ -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
|
|
31
|
-
*
|
|
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 {
|
|
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 =
|
|
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
|
|
47
|
-
*
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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';
|
|
@@ -15,7 +15,7 @@ export { createPostgresAuditLog } from './store/audit.js';
|
|
|
15
15
|
export type { FoundryStoreDb } from './store/postgres.js';
|
|
16
16
|
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';
|
|
17
17
|
export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, PlanDetail, PlatformPlanDetail, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
|
|
18
|
-
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';
|
|
18
|
+
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, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
19
19
|
export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
|
|
20
20
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
|
|
21
21
|
export type { PlatformOrgClientFactories } from './plan/index.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,18 +1,18 @@
|
|
|
1
|
-
export { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, appEntry, validateBuyerIdentity, validateHosting, validateMail,
|
|
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';
|
|
5
5
|
export { 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
|
-
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';
|
|
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, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
9
9
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
|
|
10
10
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
|
|
11
11
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
|
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
|
-
*
|
|
104
|
-
* While off, `apply` plans no
|
|
105
|
-
* alone, listed in `unmanaged`;
|
|
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
|
-
*
|
|
443
|
-
* its ZITADEL org by email so they can set their own first
|
|
444
|
-
* passkey) without a password ever passing through foundry.
|
|
445
|
-
*
|
|
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`
|
|
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
|
|
450
|
-
* recorded invited user id, not on this declared value, so editing
|
|
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 `
|
|
460
|
-
* {@link validateBuyerIdentity} and `../slug.ts`'s `validateSlug`.
|
|
461
|
-
*
|
|
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
|
|
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'>
|
|
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
|
|
581
|
-
|
|
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
|
-
|
|
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
|
|
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 `
|
|
343
|
-
* {@link validateBuyerIdentity} and `../slug.ts`'s `validateSlug`.
|
|
344
|
-
*
|
|
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
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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) {
|
package/dist/plan/agora.d.ts
CHANGED
|
@@ -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>>,
|
|
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
|
};
|