@wtfalch/foundry 0.45.1 → 0.46.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/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/org.d.ts +37 -0
- package/dist/org.js +135 -10
- package/dist/plan/agora.d.ts +14 -0
- package/dist/plan/agora.js +21 -0
- package/dist/plan/backup-gate.d.ts +15 -0
- package/dist/plan/backup-gate.js +38 -0
- package/dist/plan/boule-owners-http.d.ts +12 -0
- package/dist/plan/boule-owners-http.js +59 -0
- package/dist/plan/boule.d.ts +38 -2
- package/dist/plan/boule.js +72 -1
- package/dist/plan/company-backups.d.ts +7 -1
- package/dist/plan/company-backups.js +42 -2
- package/dist/plan/cookie-domain.d.ts +41 -0
- package/dist/plan/cookie-domain.js +71 -0
- package/dist/plan/desktop-auth.d.ts +58 -0
- package/dist/plan/desktop-auth.js +144 -0
- package/dist/plan/desktop-callback.d.ts +26 -0
- package/dist/plan/desktop-callback.js +59 -0
- package/dist/plan/diff.d.ts +13 -4
- package/dist/plan/diff.js +272 -84
- package/dist/plan/executor.d.ts +136 -6
- package/dist/plan/executor.js +725 -98
- package/dist/plan/health-gaps.js +5 -0
- package/dist/plan/index.d.ts +3 -0
- package/dist/plan/index.js +2 -0
- package/dist/plan/job-app.d.ts +23 -0
- package/dist/plan/job-app.js +41 -0
- package/dist/plan/live.d.ts +6 -0
- package/dist/plan/live.js +16 -0
- package/dist/plan/login-host.d.ts +28 -0
- package/dist/plan/login-host.js +36 -0
- package/dist/plan/org-names.js +9 -1
- package/dist/plan/org-postgres-job.d.ts +22 -0
- package/dist/plan/org-postgres-job.js +91 -0
- package/dist/plan/org-postgres-migrate.d.ts +115 -0
- package/dist/plan/org-postgres-migrate.js +466 -0
- package/dist/plan/org-postgres.d.ts +90 -0
- package/dist/plan/org-postgres.js +293 -0
- package/dist/plan/plan-apply.d.ts +30 -2
- package/dist/plan/plan-apply.js +104 -7
- package/dist/plan/platform-diff.d.ts +45 -0
- package/dist/plan/platform-diff.js +159 -4
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-org.js +4 -0
- package/dist/plan/platform-providers.d.ts +12 -2
- package/dist/plan/platform-providers.js +279 -9
- package/dist/plan/post-logout-uris.d.ts +22 -0
- package/dist/plan/post-logout-uris.js +43 -0
- package/dist/plan/providers-readers.d.ts +10 -1
- package/dist/plan/providers-readers.js +41 -1
- package/dist/plan/sign-in-bindings.d.ts +4 -0
- package/dist/plan/sign-in-bindings.js +10 -0
- package/dist/plan/sign-in-org.d.ts +19 -6
- package/dist/plan/sign-in-org.js +35 -10
- package/dist/plan/sign-in.d.ts +12 -5
- package/dist/plan/sign-in.js +12 -5
- package/dist/plan/signin-db-move.js +10 -34
- package/package.json +5 -5
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, WorkerAppConfig, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceSchema, ServicesConfig, SignInConfig, VersionPin, VersionPinPart, } from './org.js';
|
|
2
|
-
export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateCustomers, validateSignIn, validateVersionPin, validateSite, } from './org.js';
|
|
1
|
+
export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, WorkerAppConfig, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceDatabase, OrgServiceSchema, ServicesConfig, SignInConfig, VersionPin, VersionPinPart, } from './org.js';
|
|
2
|
+
export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, orgServiceDatabase, orgServiceDatabases, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateCustomers, validateSignIn, validateVersionPin, validateSite, } from './org.js';
|
|
3
3
|
export type { DeclaredPlatform, PlatformService, PlatformServiceKey, PlatformSfuService, HeartbeatsProvider, } from './platform.js';
|
|
4
4
|
export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, validatePlatform, } from './platform.js';
|
|
5
5
|
import { type PinnedVersions } from './bootstrap/versions.js';
|
|
@@ -25,6 +25,8 @@ export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAudit
|
|
|
25
25
|
export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, StepSystem, PlanDetail, OrgPostgresPlan, PlatformPlanDetail, MailRoute, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, FoundryApplyOptions, UnconfirmedUpgrade, } from './plan/index.js';
|
|
26
26
|
export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINATIONS, DestinationCapacityError, PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, placed, placedOnPlatform, validateNetworkName, } from './plan/index.js';
|
|
27
27
|
export { AdoptRefusedError } from './plan/index.js';
|
|
28
|
+
export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationBackupMissingError, OrgPostgresMigrationNotConfirmedError, } from './plan/index.js';
|
|
29
|
+
export type { OrgPostgresMigrationConfirmation } from './plan/index.js';
|
|
28
30
|
export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './plan/index.js';
|
|
29
31
|
export type { BackupGatePorts } from './plan/index.js';
|
|
30
32
|
export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateCustomers, validateSignIn, validateVersionPin, validateSite, } from './org.js';
|
|
1
|
+
export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, orgServiceDatabase, orgServiceDatabases, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateCustomers, validateSignIn, validateVersionPin, validateSite, } from './org.js';
|
|
2
2
|
export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, validatePlatform, } from './platform.js';
|
|
3
3
|
// archon#273: the pinned platform service versions the planner deploys, so a
|
|
4
4
|
// host can show declared against running. Readonly: it is the planner's own
|
|
@@ -15,6 +15,7 @@ export { createPostgresReleaseStore } from './store/postgres.js';
|
|
|
15
15
|
export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryReleaseRequests, foundryRequestedChanges, } from './store/schema.js';
|
|
16
16
|
export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINATIONS, DestinationCapacityError, PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, placed, placedOnPlatform, validateNetworkName, } from './plan/index.js';
|
|
17
17
|
export { AdoptRefusedError } from './plan/index.js';
|
|
18
|
+
export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationBackupMissingError, OrgPostgresMigrationNotConfirmedError, } from './plan/index.js';
|
|
18
19
|
export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './plan/index.js';
|
|
19
20
|
export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
|
|
20
21
|
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, 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, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
package/dist/org.d.ts
CHANGED
|
@@ -857,7 +857,19 @@ export interface OwnerIdentity {
|
|
|
857
857
|
givenName: string;
|
|
858
858
|
familyName: string;
|
|
859
859
|
mailboxAddress?: string;
|
|
860
|
+
/** Their position in the company, free text such as "CEO". Seeded into Boule once; Boule owns it afterwards. */
|
|
861
|
+
roleAtCompany?: string;
|
|
862
|
+
/** Free text, seeded into Boule once. */
|
|
863
|
+
phone?: string;
|
|
860
864
|
}
|
|
865
|
+
/**
|
|
866
|
+
* `null` when the owner's sign-in name (the part of the work address before the
|
|
867
|
+
* `@`) and display names may be used; otherwise a fixed sentence. The sign-in
|
|
868
|
+
* name gets the broker's whole check, reserved names included. The given and
|
|
869
|
+
* family names are free text and only refuse a slur. The sentence never holds
|
|
870
|
+
* the value.
|
|
871
|
+
*/
|
|
872
|
+
export declare function ownerNameProblem(mailboxAddress: string | null | undefined, givenName: string, familyName: string): string | null;
|
|
861
873
|
/**
|
|
862
874
|
* `null` when `owners` is valid; otherwise the reason it is not, same shape as
|
|
863
875
|
* {@link validateBuyerIdentity} and `../slug.ts`'s `validateSlug`. An empty
|
|
@@ -1086,6 +1098,10 @@ export interface OrgServiceSchema {
|
|
|
1086
1098
|
runtimeRole: string;
|
|
1087
1099
|
/** Where this schema's existence is recorded, once provisioned. */
|
|
1088
1100
|
scopeKey: string;
|
|
1101
|
+
/** False for the four matrix homes: one login role is owner and runtime, because Synapse and MAS run their own migrations. */
|
|
1102
|
+
split: boolean;
|
|
1103
|
+
/** The recorded-id key of this home's Coolify database today, or null when the home has none (desktop-auth, or a company with no app database yet). A live company stays on that database until its migration records the home as moved. */
|
|
1104
|
+
legacyKey: (org: DeclaredOrg) => string | null;
|
|
1089
1105
|
}
|
|
1090
1106
|
/**
|
|
1091
1107
|
* The full, fixed schema topology every org's shared Postgres server
|
|
@@ -1119,3 +1135,24 @@ export interface OrgServiceSchema {
|
|
|
1119
1135
|
* create.
|
|
1120
1136
|
*/
|
|
1121
1137
|
export declare const ORG_SERVICE_SCHEMAS: readonly OrgServiceSchema[];
|
|
1138
|
+
/** One service's home on the org's Postgres server, with the names it is created under (#444). */
|
|
1139
|
+
export interface OrgServiceDatabase {
|
|
1140
|
+
/** The {@link ORG_SERVICE_SCHEMAS} key. */
|
|
1141
|
+
schema: string;
|
|
1142
|
+
database: string;
|
|
1143
|
+
ownerRole: string;
|
|
1144
|
+
/** Equals {@link ownerRole} when {@link split} is false. */
|
|
1145
|
+
runtimeRole: string;
|
|
1146
|
+
split: boolean;
|
|
1147
|
+
/** Synapse only: created from template0 with LC_COLLATE and LC_CTYPE 'C'. */
|
|
1148
|
+
collateC: boolean;
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Pure. The names a home is created under on the org's server. A role name is
|
|
1152
|
+
* cluster-wide, so the live runtime role names are kept: where a service's
|
|
1153
|
+
* runtime role is `<database>_rt` today, the database keeps its live name.
|
|
1154
|
+
* A new and a migrated company use the same names.
|
|
1155
|
+
*/
|
|
1156
|
+
export declare function orgServiceDatabase(org: DeclaredOrg, home: OrgServiceSchema): OrgServiceDatabase;
|
|
1157
|
+
/** Every home's names for `org`. Throws when two homes would share a database or a role (a slug such as `chat` collides with chat's own service). */
|
|
1158
|
+
export declare function orgServiceDatabases(org: DeclaredOrg): OrgServiceDatabase[];
|
package/dist/org.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEFAULT_RULES, checkName, explain } from '@wtfalch/auth/names';
|
|
1
2
|
import { floatingRefBlocked, floatingTagBlocked } from './bootstrap/versions.js';
|
|
2
3
|
import { entryHasDatabase, validateArtifact, validateEntryWorkerApp, } from './worker-app-config.js';
|
|
3
4
|
/** {@link MailConfig.mailApps} when the org declares none of its own (#34, decided 2026-09-30). */
|
|
@@ -1055,8 +1056,49 @@ export function validateBuyerIdentity(buyer) {
|
|
|
1055
1056
|
}
|
|
1056
1057
|
return null;
|
|
1057
1058
|
}
|
|
1059
|
+
/** Longest `roleAtCompany` and `phone`: the limits Boule's owner-seeding door enforces. */
|
|
1060
|
+
const OWNER_ROLE_MAX = 100;
|
|
1061
|
+
const OWNER_PHONE_MAX = 40;
|
|
1062
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: the point is to refuse them
|
|
1063
|
+
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
|
|
1064
|
+
/** A fixed sentence when an optional owner text field is not usable, `null` when absent or fine. The value is never in it. */
|
|
1065
|
+
function ownerTextProblem(owner, field, max) {
|
|
1066
|
+
const value = owner[field];
|
|
1067
|
+
if (value === undefined)
|
|
1068
|
+
return null;
|
|
1069
|
+
const trimmed = value.trim();
|
|
1070
|
+
if (!trimmed || trimmed.length > max || CONTROL_CHARACTER.test(value)) {
|
|
1071
|
+
return `owner ${owner.email} ${field} must be 1 to ${max} characters with no control character`;
|
|
1072
|
+
}
|
|
1073
|
+
return null;
|
|
1074
|
+
}
|
|
1058
1075
|
/** 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. */
|
|
1059
1076
|
const OWNER_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
1077
|
+
const ANY_TEXT = { minLength: 0, maxLength: Number.MAX_SAFE_INTEGER, pattern: /^/ };
|
|
1078
|
+
/**
|
|
1079
|
+
* `null` when the owner's sign-in name (the part of the work address before the
|
|
1080
|
+
* `@`) and display names may be used; otherwise a fixed sentence. The sign-in
|
|
1081
|
+
* name gets the broker's whole check, reserved names included. The given and
|
|
1082
|
+
* family names are free text and only refuse a slur. The sentence never holds
|
|
1083
|
+
* the value.
|
|
1084
|
+
*/
|
|
1085
|
+
export function ownerNameProblem(mailboxAddress, givenName, familyName) {
|
|
1086
|
+
if (mailboxAddress) {
|
|
1087
|
+
const local = mailboxAddress.slice(0, mailboxAddress.lastIndexOf('@'));
|
|
1088
|
+
const verdict = checkName(local, DEFAULT_RULES);
|
|
1089
|
+
if (!verdict.ok)
|
|
1090
|
+
return `the work address name before the @ is refused: ${explain(verdict.reason)}`;
|
|
1091
|
+
}
|
|
1092
|
+
for (const [label, value] of [
|
|
1093
|
+
['givenName', givenName],
|
|
1094
|
+
['familyName', familyName],
|
|
1095
|
+
]) {
|
|
1096
|
+
const verdict = checkName(value, ANY_TEXT);
|
|
1097
|
+
if (!verdict.ok && verdict.reason === 'disallowed')
|
|
1098
|
+
return `${label} is not allowed`;
|
|
1099
|
+
}
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1060
1102
|
/**
|
|
1061
1103
|
* `null` when `owners` is valid; otherwise the reason it is not, same shape as
|
|
1062
1104
|
* {@link validateBuyerIdentity} and `../slug.ts`'s `validateSlug`. An empty
|
|
@@ -1078,6 +1120,13 @@ export function validateOwners(owners) {
|
|
|
1078
1120
|
if (owner.mailboxAddress !== undefined && !OWNER_EMAIL_PATTERN.test(owner.mailboxAddress)) {
|
|
1079
1121
|
return `owner ${owner.email} mailboxAddress '${owner.mailboxAddress}' is not a valid email address`;
|
|
1080
1122
|
}
|
|
1123
|
+
const problem = ownerNameProblem(owner.mailboxAddress, owner.givenName, owner.familyName);
|
|
1124
|
+
if (problem)
|
|
1125
|
+
return `owner ${owner.email}: ${problem}`;
|
|
1126
|
+
const textProblem = ownerTextProblem(owner, 'roleAtCompany', OWNER_ROLE_MAX) ??
|
|
1127
|
+
ownerTextProblem(owner, 'phone', OWNER_PHONE_MAX);
|
|
1128
|
+
if (textProblem)
|
|
1129
|
+
return textProblem;
|
|
1081
1130
|
if (owner.mailboxAddress !== undefined) {
|
|
1082
1131
|
const box = owner.mailboxAddress.toLowerCase();
|
|
1083
1132
|
if (boxes.has(box))
|
|
@@ -1283,13 +1332,15 @@ export function validateMail(org, opts) {
|
|
|
1283
1332
|
*/
|
|
1284
1333
|
export const ORG_POSTGRES_SERVER_SCOPE_KEY = 'org_postgres_server';
|
|
1285
1334
|
/** Builds one {@link OrgServiceSchema}: its owner role, runtime role and scope key all follow from {@link schema} alone, so every caller gets the same shape. */
|
|
1286
|
-
function orgServiceSchema(service, schema) {
|
|
1335
|
+
function orgServiceSchema(service, schema, legacyKey, split = true) {
|
|
1287
1336
|
return {
|
|
1288
1337
|
service,
|
|
1289
1338
|
schema,
|
|
1290
1339
|
ownerRole: `${schema}_owner`,
|
|
1291
1340
|
runtimeRole: `${schema}_rt`,
|
|
1292
1341
|
scopeKey: `org_postgres_schema.${schema}`,
|
|
1342
|
+
split,
|
|
1343
|
+
legacyKey,
|
|
1293
1344
|
};
|
|
1294
1345
|
}
|
|
1295
1346
|
/**
|
|
@@ -1324,13 +1375,87 @@ function orgServiceSchema(service, schema) {
|
|
|
1324
1375
|
* create.
|
|
1325
1376
|
*/
|
|
1326
1377
|
export const ORG_SERVICE_SCHEMAS = [
|
|
1327
|
-
orgServiceSchema('Boule', 'boule'),
|
|
1328
|
-
orgServiceSchema('CMS', 'cms'),
|
|
1329
|
-
orgServiceSchema("chat's Synapse homeserver", 'chat_synapse'),
|
|
1330
|
-
orgServiceSchema("chat's matrix-authentication-service", 'chat_mas'),
|
|
1331
|
-
orgServiceSchema("chat's own service", 'chat_service'),
|
|
1332
|
-
orgServiceSchema("calls' Synapse homeserver", 'calls_synapse'),
|
|
1333
|
-
orgServiceSchema("calls' matrix-authentication-service", 'calls_mas'),
|
|
1334
|
-
orgServiceSchema("calls' own service", 'calls_service'),
|
|
1335
|
-
orgServiceSchema("the org's own app", 'app'),
|
|
1378
|
+
orgServiceSchema('Boule', 'boule', () => 'coolify_boule_database'),
|
|
1379
|
+
orgServiceSchema('CMS', 'cms', () => 'coolify_cms_database'),
|
|
1380
|
+
orgServiceSchema("chat's Synapse homeserver", 'chat_synapse', () => 'coolify_chat_synapse_database', false),
|
|
1381
|
+
orgServiceSchema("chat's matrix-authentication-service", 'chat_mas', () => 'coolify_chat_mas_database', false),
|
|
1382
|
+
orgServiceSchema("chat's own service", 'chat_service', () => 'coolify_chat_service_database'),
|
|
1383
|
+
orgServiceSchema("calls' Synapse homeserver", 'calls_synapse', () => 'coolify_calls_synapse_database', false),
|
|
1384
|
+
orgServiceSchema("calls' matrix-authentication-service", 'calls_mas', () => 'coolify_calls_mas_database', false),
|
|
1385
|
+
orgServiceSchema("calls' own service", 'calls_service', () => 'coolify_calls_service_database'),
|
|
1386
|
+
orgServiceSchema("the org's own app", 'app', (org) => `coolify:database:${org.id}`),
|
|
1387
|
+
orgServiceSchema('desktop-auth', 'desktop_auth', () => null),
|
|
1336
1388
|
];
|
|
1389
|
+
const POSTGRES_NAME = /^[a-z][a-z0-9_]{0,62}$/;
|
|
1390
|
+
function postgresName(name) {
|
|
1391
|
+
if (!POSTGRES_NAME.test(name))
|
|
1392
|
+
throw new Error(`Postgres name is not valid: ${name}`);
|
|
1393
|
+
return name;
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* Pure. The names a home is created under on the org's server. A role name is
|
|
1397
|
+
* cluster-wide, so the live runtime role names are kept: where a service's
|
|
1398
|
+
* runtime role is `<database>_rt` today, the database keeps its live name.
|
|
1399
|
+
* A new and a migrated company use the same names.
|
|
1400
|
+
*/
|
|
1401
|
+
export function orgServiceDatabase(org, home) {
|
|
1402
|
+
const underscored = (name) => name.replace(/-/g, '_');
|
|
1403
|
+
let database;
|
|
1404
|
+
let runtimeRole = null;
|
|
1405
|
+
switch (home.schema) {
|
|
1406
|
+
case 'boule':
|
|
1407
|
+
database = underscored(`${org.slug}-boule-db`);
|
|
1408
|
+
break;
|
|
1409
|
+
case 'cms':
|
|
1410
|
+
database = underscored(`${org.slug}-cms-db`);
|
|
1411
|
+
runtimeRole = 'cms_rt';
|
|
1412
|
+
break;
|
|
1413
|
+
case 'chat_service':
|
|
1414
|
+
database = 'chat';
|
|
1415
|
+
break;
|
|
1416
|
+
case 'calls_service':
|
|
1417
|
+
database = underscored(`${org.slug}-calls-db`);
|
|
1418
|
+
break;
|
|
1419
|
+
case 'app':
|
|
1420
|
+
database = underscored(org.slug);
|
|
1421
|
+
break;
|
|
1422
|
+
default:
|
|
1423
|
+
database = home.schema;
|
|
1424
|
+
}
|
|
1425
|
+
postgresName(database);
|
|
1426
|
+
if (!home.split) {
|
|
1427
|
+
return {
|
|
1428
|
+
schema: home.schema,
|
|
1429
|
+
database,
|
|
1430
|
+
ownerRole: postgresName(`${database}_owner`),
|
|
1431
|
+
runtimeRole: postgresName(`${database}_owner`),
|
|
1432
|
+
split: false,
|
|
1433
|
+
collateC: home.schema.endsWith('_synapse'),
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
return {
|
|
1437
|
+
schema: home.schema,
|
|
1438
|
+
database,
|
|
1439
|
+
ownerRole: postgresName(`${database}_owner`),
|
|
1440
|
+
runtimeRole: postgresName(runtimeRole ?? `${database}_rt`),
|
|
1441
|
+
split: true,
|
|
1442
|
+
collateC: false,
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
/** Every home's names for `org`. Throws when two homes would share a database or a role (a slug such as `chat` collides with chat's own service). */
|
|
1446
|
+
export function orgServiceDatabases(org) {
|
|
1447
|
+
const all = ORG_SERVICE_SCHEMAS.map((home) => orgServiceDatabase(org, home));
|
|
1448
|
+
const databases = new Set();
|
|
1449
|
+
const roles = new Set();
|
|
1450
|
+
for (const entry of all) {
|
|
1451
|
+
if (databases.has(entry.database))
|
|
1452
|
+
throw new Error(`Postgres database is used by two homes: ${entry.database}`);
|
|
1453
|
+
databases.add(entry.database);
|
|
1454
|
+
for (const role of new Set([entry.ownerRole, entry.runtimeRole])) {
|
|
1455
|
+
if (roles.has(role))
|
|
1456
|
+
throw new Error(`Postgres role is used by two homes: ${role}`);
|
|
1457
|
+
roles.add(role);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
return all;
|
|
1461
|
+
}
|
package/dist/plan/agora.d.ts
CHANGED
|
@@ -12,6 +12,20 @@ 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
|
+
* #440: Agora's desktop sign-in backend, a second app that holds the
|
|
17
|
+
* messaging desktop app's handoff state. The app's id is recorded as soon as
|
|
18
|
+
* it exists; the deployed marker only once it ran healthy, and it is what
|
|
19
|
+
* tells `desiredAgoraEnv` to write the upstream, so Agora never proxies to an
|
|
20
|
+
* address that does not answer.
|
|
21
|
+
*/
|
|
22
|
+
export declare const DESKTOP_AUTH_APP_SCOPE_KEY = "coolify_desktop_auth_app";
|
|
23
|
+
export declare const DESKTOP_AUTH_DEPLOYED_SCOPE_KEY = "coolify_desktop_auth_deployed";
|
|
24
|
+
export declare const DESKTOP_AUTH_PORT = "8080";
|
|
25
|
+
/** The desktop-auth app's fixed internal name; Agora reaches it as `<name>:8080` on the company network. */
|
|
26
|
+
export declare function desktopAuthInternalName(org: Pick<DeclaredOrg, 'slug'>): string;
|
|
27
|
+
/** The value of Agora's `AGORA_DESKTOP_AUTH_UPSTREAM`. */
|
|
28
|
+
export declare function desktopAuthUpstream(org: Pick<DeclaredOrg, 'slug'>): string;
|
|
15
29
|
/**
|
|
16
30
|
* The org's ZITADEL messaging project: the audience chat-service requires in
|
|
17
31
|
* a token's `aud`, and Agora's `messagingProjectId`. `executor.ts`'s
|
package/dist/plan/agora.js
CHANGED
|
@@ -14,6 +14,24 @@ import { SIGN_IN_UPSTREAM_ENV, inAppHostApplied, signInUpstream, staleSignInUpst
|
|
|
14
14
|
* (recorded by `executor.ts`'s `zitadelApp`).
|
|
15
15
|
*/
|
|
16
16
|
export const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
|
|
17
|
+
/**
|
|
18
|
+
* #440: Agora's desktop sign-in backend, a second app that holds the
|
|
19
|
+
* messaging desktop app's handoff state. The app's id is recorded as soon as
|
|
20
|
+
* it exists; the deployed marker only once it ran healthy, and it is what
|
|
21
|
+
* tells `desiredAgoraEnv` to write the upstream, so Agora never proxies to an
|
|
22
|
+
* address that does not answer.
|
|
23
|
+
*/
|
|
24
|
+
export const DESKTOP_AUTH_APP_SCOPE_KEY = 'coolify_desktop_auth_app';
|
|
25
|
+
export const DESKTOP_AUTH_DEPLOYED_SCOPE_KEY = 'coolify_desktop_auth_deployed';
|
|
26
|
+
export const DESKTOP_AUTH_PORT = '8080';
|
|
27
|
+
/** The desktop-auth app's fixed internal name; Agora reaches it as `<name>:8080` on the company network. */
|
|
28
|
+
export function desktopAuthInternalName(org) {
|
|
29
|
+
return `${org.slug}-desktop-auth`;
|
|
30
|
+
}
|
|
31
|
+
/** The value of Agora's `AGORA_DESKTOP_AUTH_UPSTREAM`. */
|
|
32
|
+
export function desktopAuthUpstream(org) {
|
|
33
|
+
return `${desktopAuthInternalName(org)}:${DESKTOP_AUTH_PORT}`;
|
|
34
|
+
}
|
|
17
35
|
/**
|
|
18
36
|
* The org's ZITADEL messaging project: the audience chat-service requires in
|
|
19
37
|
* a token's `aud`, and Agora's `messagingProjectId`. `executor.ts`'s
|
|
@@ -294,6 +312,9 @@ export function desiredAgoraEnv(org, recorded, rawHost) {
|
|
|
294
312
|
...(inAppHostApplied(org, recorded, agoraHostnameFor(org)) ? { signInInApp: true } : {}),
|
|
295
313
|
});
|
|
296
314
|
}
|
|
315
|
+
if (recorded[DESKTOP_AUTH_DEPLOYED_SCOPE_KEY]) {
|
|
316
|
+
env.AGORA_DESKTOP_AUTH_UPSTREAM = desktopAuthUpstream(org);
|
|
317
|
+
}
|
|
297
318
|
if (org.mail.mode !== 'off' && host.mailUpstream) {
|
|
298
319
|
env.AGORA_MAIL_UPSTREAM = host.mailUpstream;
|
|
299
320
|
}
|
|
@@ -37,6 +37,15 @@ export declare class SigninDbMoveBackupMissingError extends Error {
|
|
|
37
37
|
readonly reason: 'no-gate' | 'no-backup';
|
|
38
38
|
constructor(reason: 'no-gate' | 'no-backup');
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The migration of a live company's database to its Postgres server (#444) is refused before any step
|
|
42
|
+
* runs when the old database has no finished backup from the last {@link BACKUP_MAX_AGE_HOURS} hours, or
|
|
43
|
+
* the host has no backup gate. The message names no database, uuid or URL. There is no override.
|
|
44
|
+
*/
|
|
45
|
+
export declare class OrgPostgresMigrationBackupMissingError extends Error {
|
|
46
|
+
readonly reason: 'no-gate' | 'no-backup';
|
|
47
|
+
constructor(reason: 'no-gate' | 'no-backup');
|
|
48
|
+
}
|
|
40
49
|
export interface BackupGatePorts {
|
|
41
50
|
/** The company's recorded ids (`AppliedState.serviceIds`): database uuids and `backup_<db>` schedule uuids. */
|
|
42
51
|
recordedIds: Readonly<Record<string, string>>;
|
|
@@ -83,6 +92,12 @@ export declare function requireFreshBackupsForUpgrades(plan: {
|
|
|
83
92
|
* uuid of the execution that passed.
|
|
84
93
|
*/
|
|
85
94
|
export declare function requireFreshBackupForSigninMove(ports: BackupGatePorts): Promise<string>;
|
|
95
|
+
/**
|
|
96
|
+
* The org Postgres migration's gate (#444): the old database of `home` needs a schedule and a `success`
|
|
97
|
+
* execution not older than {@link BACKUP_MAX_AGE_HOURS} hours, read from Coolify's executions. A schedule
|
|
98
|
+
* alone proves no dump ran. Returns the uuid of the execution that passed.
|
|
99
|
+
*/
|
|
100
|
+
export declare function requireFreshBackupForOrgPostgresMigration(ports: BackupGatePorts, legacyDatabaseKey: string, backupName: string): Promise<string>;
|
|
86
101
|
export interface MoveGatePorts {
|
|
87
102
|
/** The company's Coolify project (`coolify_project`). */
|
|
88
103
|
projectUuid: string | undefined;
|
package/dist/plan/backup-gate.js
CHANGED
|
@@ -59,6 +59,21 @@ export class SigninDbMoveBackupMissingError extends Error {
|
|
|
59
59
|
this.name = 'SigninDbMoveBackupMissingError';
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* The migration of a live company's database to its Postgres server (#444) is refused before any step
|
|
64
|
+
* runs when the old database has no finished backup from the last {@link BACKUP_MAX_AGE_HOURS} hours, or
|
|
65
|
+
* the host has no backup gate. The message names no database, uuid or URL. There is no override.
|
|
66
|
+
*/
|
|
67
|
+
export class OrgPostgresMigrationBackupMissingError extends Error {
|
|
68
|
+
reason;
|
|
69
|
+
constructor(reason) {
|
|
70
|
+
super(reason === 'no-gate'
|
|
71
|
+
? 'org postgres migration refused: this host has no backup gate, so a fresh backup cannot be checked'
|
|
72
|
+
: `org postgres migration refused: the old database has no finished backup from the last ${BACKUP_MAX_AGE_HOURS} hours, checked in Coolify; take one now and apply again`);
|
|
73
|
+
this.reason = reason;
|
|
74
|
+
this.name = 'OrgPostgresMigrationBackupMissingError';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
62
77
|
async function withinTimeout(call) {
|
|
63
78
|
let timer;
|
|
64
79
|
try {
|
|
@@ -224,6 +239,29 @@ export async function requireFreshBackupForSigninMove(ports) {
|
|
|
224
239
|
throw new SigninDbMoveBackupMissingError('no-backup');
|
|
225
240
|
return fresh.uuid;
|
|
226
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* The org Postgres migration's gate (#444): the old database of `home` needs a schedule and a `success`
|
|
244
|
+
* execution not older than {@link BACKUP_MAX_AGE_HOURS} hours, read from Coolify's executions. A schedule
|
|
245
|
+
* alone proves no dump ran. Returns the uuid of the execution that passed.
|
|
246
|
+
*/
|
|
247
|
+
export async function requireFreshBackupForOrgPostgresMigration(ports, legacyDatabaseKey, backupName) {
|
|
248
|
+
const now = (ports.now ?? Date.now)();
|
|
249
|
+
const databaseUuid = ports.recordedIds[legacyDatabaseKey];
|
|
250
|
+
const backupUuid = ports.recordedIds[backupScopeKey(backupName)];
|
|
251
|
+
if (!databaseUuid || !backupUuid)
|
|
252
|
+
throw new OrgPostgresMigrationBackupMissingError('no-backup');
|
|
253
|
+
let executions;
|
|
254
|
+
try {
|
|
255
|
+
executions = await readExecutions(ports.coolify, databaseUuid, backupUuid);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
throw new OrgPostgresMigrationBackupMissingError('no-backup');
|
|
259
|
+
}
|
|
260
|
+
const fresh = executions ? freshSuccess(executions, now) : null;
|
|
261
|
+
if (!fresh)
|
|
262
|
+
throw new OrgPostgresMigrationBackupMissingError('no-backup');
|
|
263
|
+
return fresh.uuid;
|
|
264
|
+
}
|
|
227
265
|
/**
|
|
228
266
|
* The gate for a move to the company's own network, which deletes every database of the project. It
|
|
229
267
|
* fails closed and is stricter than the upgrade gate: it reads the project itself, not foundry's records.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BouleOwnersPort } from './executor.js';
|
|
2
|
+
/** Thrown for any answer or failure of Boule's owner-seeding door. Carries the HTTP status (0 for no answer) and nothing else, so no secret or body can reach a message. */
|
|
3
|
+
export declare class BouleOwnersError extends Error {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
constructor(status: number);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The real {@link BouleOwnersPort}: `POST https://<hostname>/v1/organisations/<id>/owners/seed`
|
|
9
|
+
* with the secret as a bearer token. 10 s per call, no redirect, three tries on a
|
|
10
|
+
* network error or a 5xx answer. Any other answer ends at once.
|
|
11
|
+
*/
|
|
12
|
+
export declare function httpBouleOwners(fetchImpl?: typeof fetch): BouleOwnersPort;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/** Thrown for any answer or failure of Boule's owner-seeding door. Carries the HTTP status (0 for no answer) and nothing else, so no secret or body can reach a message. */
|
|
3
|
+
export class BouleOwnersError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
constructor(status) {
|
|
6
|
+
super(`Boule's owner-seeding door ${status === 0 ? 'did not answer' : `answered ${status}`}`);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.name = 'BouleOwnersError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const resultsSchema = z.object({
|
|
12
|
+
results: z.array(z.object({
|
|
13
|
+
subject: z.string(),
|
|
14
|
+
outcome: z.enum(['created', 'exists', 'refused']),
|
|
15
|
+
filled: z.array(z.string()),
|
|
16
|
+
code: z.string().optional(),
|
|
17
|
+
})),
|
|
18
|
+
});
|
|
19
|
+
const TIMEOUT_MS = 10_000;
|
|
20
|
+
const ATTEMPTS = 3;
|
|
21
|
+
/**
|
|
22
|
+
* The real {@link BouleOwnersPort}: `POST https://<hostname>/v1/organisations/<id>/owners/seed`
|
|
23
|
+
* with the secret as a bearer token. 10 s per call, no redirect, three tries on a
|
|
24
|
+
* network error or a 5xx answer. Any other answer ends at once.
|
|
25
|
+
*/
|
|
26
|
+
export function httpBouleOwners(fetchImpl = fetch) {
|
|
27
|
+
return {
|
|
28
|
+
async seed({ hostname, organisationId, secret, owners }) {
|
|
29
|
+
const url = `https://${hostname}/v1/organisations/${encodeURIComponent(organisationId)}/owners/seed`;
|
|
30
|
+
let status = 0;
|
|
31
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
32
|
+
let response;
|
|
33
|
+
try {
|
|
34
|
+
response = await fetchImpl(url, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { authorization: `Bearer ${secret}`, 'content-type': 'application/json' },
|
|
37
|
+
body: JSON.stringify({ owners }),
|
|
38
|
+
redirect: 'error',
|
|
39
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
status = 0;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
status = response.status;
|
|
47
|
+
if (status >= 500)
|
|
48
|
+
continue;
|
|
49
|
+
if (status !== 200)
|
|
50
|
+
throw new BouleOwnersError(status);
|
|
51
|
+
const parsed = resultsSchema.safeParse(await response.json().catch(() => undefined));
|
|
52
|
+
if (!parsed.success)
|
|
53
|
+
throw new BouleOwnersError(status);
|
|
54
|
+
return parsed.data;
|
|
55
|
+
}
|
|
56
|
+
throw new BouleOwnersError(status);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
package/dist/plan/boule.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type DeclaredOrg } from '../org.js';
|
|
1
|
+
import { type DeclaredOrg, type OwnerIdentity } from '../org.js';
|
|
2
2
|
import type { BrokerEntryInput, BrokerEntryWant } from './broker-entries.js';
|
|
3
3
|
/**
|
|
4
4
|
* #161 (Boule part): what both the planner (`diff.ts`) and the executor
|
|
@@ -77,6 +77,12 @@ export declare const BOULE_PEER_PASSWORD_ENV = "AUTHZ_PEER_PASSWORD_OPERATOR";
|
|
|
77
77
|
* env; compared in memory like {@link BOULE_PEER_PASSWORD_ENV}.
|
|
78
78
|
*/
|
|
79
79
|
export declare const BOULE_BRIDGE_SECRET_ENV = "ARCHON_BRIDGE_SECRET";
|
|
80
|
+
/**
|
|
81
|
+
* The secret foundry sends to Boule's owner-seeding door as a bearer token.
|
|
82
|
+
* Minted once for every org's Boule and held only in that Boule's env; foundry
|
|
83
|
+
* reads it back from there when it seeds. Not in {@link desiredBouleEnv}.
|
|
84
|
+
*/
|
|
85
|
+
export declare const BOULE_OWNER_SEED_SECRET_ENV = "OWNER_SEED_SECRET";
|
|
80
86
|
/** The role Boule creates from {@link BOULE_PEER_PASSWORD_ENV}, with grants on schema `authz` only. */
|
|
81
87
|
export declare const BOULE_PEER_ROLE = "authz_operator_rt";
|
|
82
88
|
/** The org's Archon id and the credential Boule reaches Archon with (C3). The id is the org's own; the credential is a secret. */
|
|
@@ -120,7 +126,7 @@ export interface BouleCredentialWants {
|
|
|
120
126
|
archonRenewDue?: boolean;
|
|
121
127
|
}
|
|
122
128
|
/** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
|
|
123
|
-
export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "AUTHZ_FIRST_OWNER", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN", "AUTHZ_PEER_PASSWORD_OPERATOR", "ARCHON_BRIDGE_SECRET", "ARCHON_ORG_ID", "ARCHON_CREDENTIAL", "CUSTOMERS_ORGANISATION_ID", "CUSTOMERS_DIRECTORY_CREDENTIAL", "SIGNIN_UPSTREAM"];
|
|
129
|
+
export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "AUTHZ_FIRST_OWNER", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN", "AUTHZ_PEER_PASSWORD_OPERATOR", "ARCHON_BRIDGE_SECRET", "OWNER_SEED_SECRET", "ARCHON_ORG_ID", "ARCHON_CREDENTIAL", "CUSTOMERS_ORGANISATION_ID", "CUSTOMERS_DIRECTORY_CREDENTIAL", "SIGNIN_UPSTREAM"];
|
|
124
130
|
/**
|
|
125
131
|
* The env vars an org's Boule app should carry, and every input still missing
|
|
126
132
|
* to build them. Callers that write must refuse on a non-empty `missing`: a
|
|
@@ -169,6 +175,36 @@ export declare function wantedBouleBrokerEntry(org: DeclaredOrg, recorded: Reado
|
|
|
169
175
|
* except `SIGNIN_UPSTREAM`: the switch turned off must take it away, so a live one that is not desired is drift.
|
|
170
176
|
*/
|
|
171
177
|
export declare function bouleEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
|
|
178
|
+
/** Scope id per owner once Boule answered created or exists: `boule_owner_seeded.<email lowercased>`, value '1'. */
|
|
179
|
+
export declare function bouleOwnerSeededScopeKey(email: string): string;
|
|
180
|
+
/** Scope id per owner Boule refused (`holds_other_role`, `platform_frozen`): the planner reports it blocked and sends nothing more. */
|
|
181
|
+
export declare function bouleOwnerRefusedScopeKey(email: string): string;
|
|
182
|
+
/** The ZITADEL user id foundry recorded for a declared owner, or undefined. */
|
|
183
|
+
export declare function bouleOwnerSubject(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, owner: OwnerIdentity): string | undefined;
|
|
184
|
+
/** Owners still to seed, in declared order: neither seeded nor refused yet. */
|
|
185
|
+
export declare function bouleOwnersToSeed(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): OwnerIdentity[];
|
|
186
|
+
/** One owner as Boule's owner-seeding door takes it. */
|
|
187
|
+
export interface BouleOwnerSeed {
|
|
188
|
+
subject: string;
|
|
189
|
+
givenName: string;
|
|
190
|
+
familyName: string;
|
|
191
|
+
email: string;
|
|
192
|
+
mailbox: string | null;
|
|
193
|
+
roleAtCompany: string | null;
|
|
194
|
+
phone: string | null;
|
|
195
|
+
}
|
|
196
|
+
/** The body's owners for the unseeded owners that have a recorded ZITADEL user id, in declared order. */
|
|
197
|
+
export declare function bouleOwnerSeeds(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): BouleOwnerSeed[];
|
|
198
|
+
/**
|
|
199
|
+
* The step that seeds a company's declared owners into its Boule, or the lines
|
|
200
|
+
* for owners that cannot be seeded yet (blocked, not failing). Planned only
|
|
201
|
+
* once Boule is deployed and holds the seeding secret; before that the Boule
|
|
202
|
+
* step runs first and the next plan includes this one.
|
|
203
|
+
*/
|
|
204
|
+
export declare function planBouleOwners(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, liveBouleEnv: Readonly<Record<string, string>> | null): {
|
|
205
|
+
seed: BouleOwnerSeed[];
|
|
206
|
+
blocked: string[];
|
|
207
|
+
};
|
|
172
208
|
/**
|
|
173
209
|
* The customers env a live Boule still holds after the company switched customers off. A host that merely
|
|
174
210
|
* cannot deliver the credential keeps what it has: only an explicit off removes it.
|
package/dist/plan/boule.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_NO_REPLY_LOCAL_PART } from '../org.js';
|
|
1
|
+
import { DEFAULT_NO_REPLY_LOCAL_PART, ownerMailboxes, } from '../org.js';
|
|
2
2
|
import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
|
|
3
3
|
import { SIGN_IN_UPSTREAM_ENV, inAppHostApplied, signInUpstream, staleSignInUpstream, } from './sign-in.js';
|
|
4
4
|
/**
|
|
@@ -88,6 +88,12 @@ export const BOULE_PEER_PASSWORD_ENV = 'AUTHZ_PEER_PASSWORD_OPERATOR';
|
|
|
88
88
|
* env; compared in memory like {@link BOULE_PEER_PASSWORD_ENV}.
|
|
89
89
|
*/
|
|
90
90
|
export const BOULE_BRIDGE_SECRET_ENV = 'ARCHON_BRIDGE_SECRET';
|
|
91
|
+
/**
|
|
92
|
+
* The secret foundry sends to Boule's owner-seeding door as a bearer token.
|
|
93
|
+
* Minted once for every org's Boule and held only in that Boule's env; foundry
|
|
94
|
+
* reads it back from there when it seeds. Not in {@link desiredBouleEnv}.
|
|
95
|
+
*/
|
|
96
|
+
export const BOULE_OWNER_SEED_SECRET_ENV = 'OWNER_SEED_SECRET';
|
|
91
97
|
/** The role Boule creates from {@link BOULE_PEER_PASSWORD_ENV}, with grants on schema `authz` only. */
|
|
92
98
|
export const BOULE_PEER_ROLE = 'authz_operator_rt';
|
|
93
99
|
/** The org's Archon id and the credential Boule reaches Archon with (C3). The id is the org's own; the credential is a secret. */
|
|
@@ -136,6 +142,7 @@ export const BOULE_MANAGED_ENV_KEYS = [
|
|
|
136
142
|
BOULE_MAIL_TOKEN_ENV,
|
|
137
143
|
BOULE_PEER_PASSWORD_ENV,
|
|
138
144
|
BOULE_BRIDGE_SECRET_ENV,
|
|
145
|
+
BOULE_OWNER_SEED_SECRET_ENV,
|
|
139
146
|
BOULE_ARCHON_ORG_ID_ENV,
|
|
140
147
|
BOULE_ARCHON_CREDENTIAL_ENV,
|
|
141
148
|
BOULE_CUSTOMERS_ORG_ENV,
|
|
@@ -261,6 +268,70 @@ export function bouleEnvDrift(desired, live) {
|
|
|
261
268
|
drift.push(SIGN_IN_UPSTREAM_ENV);
|
|
262
269
|
return drift;
|
|
263
270
|
}
|
|
271
|
+
/** Scope id per owner once Boule answered created or exists: `boule_owner_seeded.<email lowercased>`, value '1'. */
|
|
272
|
+
export function bouleOwnerSeededScopeKey(email) {
|
|
273
|
+
return `boule_owner_seeded.${email.toLowerCase()}`;
|
|
274
|
+
}
|
|
275
|
+
/** Scope id per owner Boule refused (`holds_other_role`, `platform_frozen`): the planner reports it blocked and sends nothing more. */
|
|
276
|
+
export function bouleOwnerRefusedScopeKey(email) {
|
|
277
|
+
return `boule_owner_refused.${email.toLowerCase()}`;
|
|
278
|
+
}
|
|
279
|
+
/** The ZITADEL user id foundry recorded for a declared owner, or undefined. */
|
|
280
|
+
export function bouleOwnerSubject(org, recorded, owner) {
|
|
281
|
+
return recorded[`zitadel:owner:${org.id}:${owner.email.toLowerCase()}`] || undefined;
|
|
282
|
+
}
|
|
283
|
+
/** Owners still to seed, in declared order: neither seeded nor refused yet. */
|
|
284
|
+
export function bouleOwnersToSeed(org, recorded) {
|
|
285
|
+
return org.owners.filter((owner) => !recorded[bouleOwnerSeededScopeKey(owner.email)] &&
|
|
286
|
+
!recorded[bouleOwnerRefusedScopeKey(owner.email)]);
|
|
287
|
+
}
|
|
288
|
+
/** The body's owners for the unseeded owners that have a recorded ZITADEL user id, in declared order. */
|
|
289
|
+
export function bouleOwnerSeeds(org, recorded) {
|
|
290
|
+
const boxes = ownerMailboxes(org);
|
|
291
|
+
const seeds = [];
|
|
292
|
+
for (const owner of bouleOwnersToSeed(org, recorded)) {
|
|
293
|
+
const subject = bouleOwnerSubject(org, recorded, owner);
|
|
294
|
+
if (!subject)
|
|
295
|
+
continue;
|
|
296
|
+
seeds.push({
|
|
297
|
+
subject,
|
|
298
|
+
givenName: owner.givenName,
|
|
299
|
+
familyName: owner.familyName,
|
|
300
|
+
email: owner.email,
|
|
301
|
+
mailbox: boxes.find((b) => b.owner === owner)?.address ?? null,
|
|
302
|
+
roleAtCompany: owner.roleAtCompany ?? null,
|
|
303
|
+
phone: owner.phone ?? null,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return seeds;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* The step that seeds a company's declared owners into its Boule, or the lines
|
|
310
|
+
* for owners that cannot be seeded yet (blocked, not failing). Planned only
|
|
311
|
+
* once Boule is deployed and holds the seeding secret; before that the Boule
|
|
312
|
+
* step runs first and the next plan includes this one.
|
|
313
|
+
*/
|
|
314
|
+
export function planBouleOwners(org, recorded, liveBouleEnv) {
|
|
315
|
+
const none = { seed: [], blocked: [] };
|
|
316
|
+
if (org.owners.length === 0)
|
|
317
|
+
return none;
|
|
318
|
+
if (!recorded[BOULE_DEPLOYED_SCOPE_KEY])
|
|
319
|
+
return none;
|
|
320
|
+
if (!liveBouleEnv?.[BOULE_OWNER_SEED_SECRET_ENV])
|
|
321
|
+
return none;
|
|
322
|
+
const blocked = [];
|
|
323
|
+
org.owners.forEach((owner, index) => {
|
|
324
|
+
const at = `${org.slug}'s Boule owners are blocked, not planned: owner ${index + 1}`;
|
|
325
|
+
if (recorded[bouleOwnerRefusedScopeKey(owner.email)]) {
|
|
326
|
+
blocked.push(`${at} was refused by Boule (it holds another role or the platform is frozen)`);
|
|
327
|
+
}
|
|
328
|
+
else if (!recorded[bouleOwnerSeededScopeKey(owner.email)] &&
|
|
329
|
+
!bouleOwnerSubject(org, recorded, owner)) {
|
|
330
|
+
blocked.push(`${at} has no sign-in account yet`);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
return { seed: bouleOwnerSeeds(org, recorded), blocked };
|
|
334
|
+
}
|
|
264
335
|
/**
|
|
265
336
|
* The customers env a live Boule still holds after the company switched customers off. A host that merely
|
|
266
337
|
* cannot deliver the credential keeps what it has: only an explicit off removes it.
|