@wtfalch/foundry 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +111 -0
  2. package/dist/bootstrap/bootstrap.js +323 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +99 -0
  5. package/dist/bootstrap/drive.d.ts +40 -0
  6. package/dist/bootstrap/drive.js +51 -0
  7. package/dist/bootstrap/index.d.ts +3 -0
  8. package/dist/bootstrap/index.js +1 -0
  9. package/dist/calls-sfu-deploy-support.d.ts +17 -6
  10. package/dist/calls-sfu-deploy-support.js +19 -7
  11. package/dist/calls-sfu-keys.d.ts +21 -0
  12. package/dist/calls-sfu-keys.js +27 -0
  13. package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
  14. package/dist/calls-sfu-ssh-adapter.js +78 -0
  15. package/dist/calls-sfu-ssh-client.d.ts +49 -0
  16. package/dist/calls-sfu-ssh-client.js +82 -0
  17. package/dist/calls-sfu.d.ts +36 -12
  18. package/dist/calls-sfu.js +41 -9
  19. package/dist/index.d.ts +38 -7
  20. package/dist/index.js +20 -4
  21. package/dist/migrations/0016_platform.sql +61 -0
  22. package/dist/migrations/0017_requested_changes.sql +42 -0
  23. package/dist/migrations/0018_platform_apply.sql +19 -0
  24. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  25. package/dist/migrations/0020_mail_apps.sql +7 -0
  26. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  27. package/dist/org.d.ts +81 -0
  28. package/dist/org.js +96 -0
  29. package/dist/plan/agora.d.ts +77 -0
  30. package/dist/plan/agora.js +127 -0
  31. package/dist/plan/cloudflared-connector.d.ts +70 -0
  32. package/dist/plan/cloudflared-connector.js +106 -0
  33. package/dist/plan/diff.d.ts +20 -3
  34. package/dist/plan/diff.js +713 -115
  35. package/dist/plan/executor.d.ts +680 -172
  36. package/dist/plan/executor.js +755 -189
  37. package/dist/plan/index.d.ts +28 -5
  38. package/dist/plan/index.js +15 -2
  39. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  40. package/dist/plan/jobs-scheduler-http.js +50 -0
  41. package/dist/plan/live.d.ts +51 -2
  42. package/dist/plan/live.js +8 -0
  43. package/dist/plan/mail-copy.d.ts +125 -0
  44. package/dist/plan/mail-copy.js +166 -0
  45. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  46. package/dist/plan/mail-identity-allowlist.js +11 -0
  47. package/dist/plan/mail-identity.d.ts +68 -0
  48. package/dist/plan/mail-identity.js +17 -0
  49. package/dist/plan/plan-apply.d.ts +197 -3
  50. package/dist/plan/plan-apply.js +278 -9
  51. package/dist/plan/platform-diff.d.ts +487 -0
  52. package/dist/plan/platform-diff.js +1220 -0
  53. package/dist/plan/platform-org-ports.d.ts +50 -0
  54. package/dist/plan/platform-org-ports.js +70 -0
  55. package/dist/plan/platform-org.d.ts +121 -0
  56. package/dist/plan/platform-org.js +115 -0
  57. package/dist/plan/platform-providers.d.ts +615 -0
  58. package/dist/plan/platform-providers.js +4410 -0
  59. package/dist/plan/platform-sfu.d.ts +128 -0
  60. package/dist/plan/platform-sfu.js +142 -0
  61. package/dist/plan/providers-readers.js +22 -1
  62. package/dist/plan/zitadel-machine-user.d.ts +83 -0
  63. package/dist/plan/zitadel-machine-user.js +238 -0
  64. package/dist/plan.d.ts +6 -0
  65. package/dist/platform.d.ts +55 -0
  66. package/dist/platform.js +46 -0
  67. package/dist/requested-changes.d.ts +118 -0
  68. package/dist/requested-changes.js +125 -0
  69. package/dist/slug.js +15 -2
  70. package/dist/store/audit.js +4 -2
  71. package/dist/store/postgres.d.ts +29 -1
  72. package/dist/store/postgres.js +203 -4
  73. package/dist/store/schema.d.ts +755 -0
  74. package/dist/store/schema.js +70 -0
  75. package/dist/store.d.ts +53 -0
  76. package/package.json +1 -1
package/dist/index.d.ts CHANGED
@@ -1,21 +1,52 @@
1
1
  export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, DeclaredOrg, } from './org.js';
2
- export { validateBuyerIdentity, validateHosting, validateOwner, validateSite } from './org.js';
2
+ export { DEFAULT_MAIL_APPS, validateBuyerIdentity, validateHosting, validateMail, validateOwner, validateSite, } from './org.js';
3
+ export type { DeclaredPlatform, PlatformService, PlatformServiceKey } from './platform.js';
4
+ export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
5
+ export type { RequestableField, CallsChange, StorageLimitChange, AiLimitChange, DomainChange, MailTierChange, RequestedChangeValue, ChangeClassification, RequestedChangeState, RequestedChange, } from './requested-changes.js';
6
+ export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
3
7
  export type { SlugRejectionReason, SlugRejection } from './slug.js';
4
8
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
5
9
  export type { Checkpoint, LastApplied, AppliedState } from './state.js';
6
10
  export type { PlanStep, UnmanagedItem, Plan } from './plan.js';
7
- export type { DeclaredOrgStore } from './store.js';
11
+ export type { DeclaredOrgStore, PlatformStore, RequestedChangeStore } from './store.js';
8
12
  export type { PlanApply } from './apply.js';
9
- export { createPostgresDeclaredOrgStore, DeclaredOrgError } from './store/postgres.js';
13
+ export { createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
10
14
  export { createPostgresAuditLog } from './store/audit.js';
11
15
  export type { FoundryStoreDb } from './store/postgres.js';
12
- export { declaredOrgs, foundryAppliedState, foundryAuditEvents, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, } from './store/schema.js';
13
- export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, StepAction, PlannedStep, PlanDetail, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
14
- export { readLiveResources, computePlanDetail, toPlan, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, } from './plan/index.js';
15
- export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, FilesOrgLimits, AiOrgLimits, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, } from './plan/index.js';
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
+ 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';
19
+ export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
20
+ export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
21
+ export type { PlatformOrgClientFactories } from './plan/index.js';
22
+ export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
23
+ export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, } from './plan/index.js';
16
24
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './plan/index.js';
25
+ export { HttpJobsSchedulerClient } from './plan/index.js';
26
+ export type { MailIdentityApp, MailIdentityAudienceCheck } from './plan/index.js';
27
+ export { checkMailIdentityAudience } from './plan/index.js';
28
+ export type { MailAllowlistApp } from './plan/index.js';
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';
31
+ export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, } from './plan/index.js';
32
+ export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './plan/index.js';
33
+ export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, } from './plan/index.js';
34
+ export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
35
+ export type { AiManagementKeyMinter, PlatformAiDeps, PlatformAiRuntime, } from './plan/index.js';
36
+ export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './plan/index.js';
37
+ export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './plan/index.js';
38
+ export type { CallsSfuSshSession } from './calls-sfu-ssh-adapter.js';
39
+ export { connectCallsSfuSsh } from './calls-sfu-ssh-client.js';
40
+ export type { CallsSfuSshConnectOptions } from './calls-sfu-ssh-client.js';
41
+ export { UnknownCallsSfuHostKeyError, toCallsSfuHostPort, applyCallsSfuOverSession, } from './calls-sfu-ssh-adapter.js';
42
+ export type { LivekitCredentials } from './calls-sfu-keys.js';
43
+ export { mintLivekitCredentials } from './calls-sfu-keys.js';
44
+ export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './plan/index.js';
45
+ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './plan/index.js';
17
46
  export type { MigrateSql, MigrationResult } from './migrate.js';
18
47
  export { MIGRATIONS_DIR, migrate } from './migrate.js';
48
+ export type { BootstrapCompany, BootstrapExecutorDeps, BootstrapOwner, BootstrapPlatformDeps, BootstrapPlatformInput, BootstrapProgress, BootstrapResult, RootCredentials, } from './bootstrap/index.js';
49
+ export { bootstrapPlatform } from './bootstrap/index.js';
19
50
  export type { RunnerDb } from './runner/db.js';
20
51
  export { AlreadyRunningError, RunNotFoundError, StepFailedError, StepMismatchError, } from './runner/errors.js';
21
52
  export { createJobRunner } from './runner/runner.js';
package/dist/index.js CHANGED
@@ -1,10 +1,26 @@
1
- export { validateBuyerIdentity, validateHosting, validateOwner, validateSite } from './org.js';
1
+ export { DEFAULT_MAIL_APPS, validateBuyerIdentity, validateHosting, validateMail, validateOwner, validateSite, } from './org.js';
2
+ export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
3
+ export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
2
4
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
3
- export { createPostgresDeclaredOrgStore, DeclaredOrgError } from './store/postgres.js';
5
+ export { createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
4
6
  export { createPostgresAuditLog } from './store/audit.js';
5
- export { declaredOrgs, foundryAppliedState, foundryAuditEvents, } from './store/schema.js';
6
- export { readLiveResources, computePlanDetail, toPlan, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, } from './plan/index.js';
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';
9
+ export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
10
+ export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
7
11
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './plan/index.js';
12
+ export { HttpJobsSchedulerClient } from './plan/index.js';
13
+ export { checkMailIdentityAudience } from './plan/index.js';
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';
16
+ export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, } from './plan/index.js';
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
+ export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './plan/index.js';
19
+ export { connectCallsSfuSsh } from './calls-sfu-ssh-client.js';
20
+ export { UnknownCallsSfuHostKeyError, toCallsSfuHostPort, applyCallsSfuOverSession, } from './calls-sfu-ssh-adapter.js';
21
+ export { mintLivekitCredentials } from './calls-sfu-keys.js';
22
+ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './plan/index.js';
8
23
  export { MIGRATIONS_DIR, migrate } from './migrate.js';
24
+ export { bootstrapPlatform } from './bootstrap/index.js';
9
25
  export { AlreadyRunningError, RunNotFoundError, StepFailedError, StepMismatchError, } from './runner/errors.js';
10
26
  export { createJobRunner } from './runner/runner.js';
@@ -0,0 +1,61 @@
1
+ -- @wtfalch/foundry: the declared platform -- the one shared set of services
2
+ -- (auth, mail, sfu, files, ai, jobs, integrations, archon) decided in #165,
3
+ -- owned by Company Included, with its own plan and apply. Mirrors
4
+ -- src/store/schema.ts's `foundryDeclaredPlatform` table.
5
+ --
6
+ -- Singleton by construction: `id` is a boolean that can only ever be `true`
7
+ -- (the check constraint below refuses `false`), and it is the primary key,
8
+ -- so a second row can never exist. This is the same "one declared platform"
9
+ -- decision `foundry_declared_orgs` enforces per-org uniqueness for (#28,
10
+ -- #131), applied here to the whole table instead of a column.
11
+
12
+ create table if not exists foundry_declared_platform (
13
+ id boolean not null default true,
14
+
15
+ -- Company Included's own OrgId (org.ts). Not a level-1 org itself, but
16
+ -- the platform's resources are billed and owned by one.
17
+ owner_org_id text not null,
18
+
19
+ -- The shared apex every service's hostname defaults under, e.g.
20
+ -- companyincluded.com. Validated by src/platform.ts's `validatePlatform`,
21
+ -- not here, the same two-file split declared_orgs uses (#10).
22
+ apex text not null,
23
+
24
+ auth_enabled boolean not null default false,
25
+ auth_hostname text not null,
26
+ auth_git_ref text not null default '',
27
+
28
+ mail_enabled boolean not null default false,
29
+ mail_hostname text not null,
30
+ mail_git_ref text not null default '',
31
+
32
+ sfu_enabled boolean not null default false,
33
+ sfu_hostname text not null,
34
+ sfu_git_ref text not null default '',
35
+
36
+ files_enabled boolean not null default false,
37
+ files_hostname text not null,
38
+ files_git_ref text not null default '',
39
+
40
+ ai_enabled boolean not null default false,
41
+ ai_hostname text not null,
42
+ ai_git_ref text not null default '',
43
+
44
+ jobs_enabled boolean not null default false,
45
+ jobs_hostname text not null,
46
+ jobs_git_ref text not null default '',
47
+
48
+ integrations_enabled boolean not null default false,
49
+ integrations_hostname text not null,
50
+ integrations_git_ref text not null default '',
51
+
52
+ archon_enabled boolean not null default false,
53
+ archon_hostname text not null,
54
+ archon_git_ref text not null default '',
55
+
56
+ created_at timestamptz not null default now(),
57
+ updated_at timestamptz not null default now(),
58
+
59
+ constraint foundry_declared_platform_pkey primary key (id),
60
+ constraint foundry_declared_platform_singleton check (id)
61
+ );
@@ -0,0 +1,42 @@
1
+ -- @wtfalch/foundry: a company's own requested setting changes on its
2
+ -- declared org (#178, map #165: "a company's Boule can request setting
3
+ -- changes on its own declared org, and Archon applies them"). Mirrors
4
+ -- src/store/schema.ts's `foundryRequestedChanges` table.
5
+ --
6
+ -- `field` is the whitelist itself, checked here the same way
7
+ -- `foundry_declared_orgs_mail_mode_check` checks `mail_mode`'s closed set --
8
+ -- src/requested-changes.ts's `REQUESTABLE_FIELDS` is the source of truth for
9
+ -- what the list means, this constraint is the database-level copy of it.
10
+ -- `value` carries whatever that field's own shape needs (`enabled` for
11
+ -- calls, `limitBytes`/`limitMicros` for storageLimit, and so on) -- one
12
+ -- jsonb column rather than a column per possible field, since only one
13
+ -- field is ever set per request.
14
+ --
15
+ -- `state` starts at whatever `src/requested-changes.ts`'s `classifyChange`
16
+ -- decided for this request at create time (`auto` or `needs_approval`) --
17
+ -- foundry only stores and classifies (#178 "Why"); archon's own workflow is
18
+ -- what carries a request on to `approved`/`rejected`/`applied`.
19
+
20
+ create table if not exists foundry_requested_changes (
21
+ id bigserial not null,
22
+ org_id text not null,
23
+
24
+ field text not null,
25
+ value jsonb not null,
26
+
27
+ requested_by text not null,
28
+ created_at timestamptz not null default now(),
29
+
30
+ state text not null default 'needs_approval',
31
+
32
+ constraint foundry_requested_changes_pkey primary key (id),
33
+ constraint foundry_requested_changes_org_fkey
34
+ foreign key (org_id) references foundry_declared_orgs (id),
35
+ constraint foundry_requested_changes_field_check
36
+ check (field in ('calls', 'storageLimit', 'aiLimit', 'domain', 'mailTier')),
37
+ constraint foundry_requested_changes_state_check
38
+ check (state in ('auto', 'needs_approval', 'approved', 'rejected', 'applied'))
39
+ );
40
+
41
+ create index if not exists foundry_requested_changes_org_id_idx
42
+ on foundry_requested_changes (org_id);
@@ -0,0 +1,19 @@
1
+ -- @wtfalch/foundry: what applying the declared platform (#167) needs to
2
+ -- remember, and how its audit rows are told apart from an org's.
3
+ --
4
+ -- `service_ids` is the platform's own applied state: the provider ids its
5
+ -- apply made or adopted, keyed `system:kind:key` like
6
+ -- `foundry_applied_state.service_ids`. Without it a Cloudflare zone created
7
+ -- by one apply is forgotten if the next plan's read lags behind the create.
8
+ -- It lives on the singleton row because there is one platform, and
9
+ -- `put`'s upsert never writes it, so re-declaring the platform keeps it.
10
+ --
11
+ -- `target` marks an audit row as the platform's. It is null for an org row.
12
+ -- A platform plan is stamped with the owner org's id, so `org_id` alone
13
+ -- cannot say which of the two a row is.
14
+
15
+ alter table foundry_declared_platform
16
+ add column if not exists service_ids jsonb not null default '{}'::jsonb;
17
+
18
+ alter table foundry_audit_events
19
+ add column if not exists target text check (target in ('platform'));
@@ -0,0 +1,15 @@
1
+ -- @wtfalch/foundry: #162 (decided 2026-09-29) -- the owner's mailbox
2
+ -- address is now declared, not guessed (replacing the hardcoded
3
+ -- `owner@<domain>` PR 163 shipped first), and the org's send-only no-reply
4
+ -- address gets its own configurable local part. Mirrors
5
+ -- `src/store/schema.ts`'s `declaredOrgs` table additions.
6
+ --
7
+ -- owner_mailbox_address is nullable: an org can have mail on with no
8
+ -- address declared yet, and `plan/diff.ts`'s `planMail` reports the
9
+ -- mailbox alone as blocked in that case rather than this column refusing
10
+ -- the row. no_reply_local_part defaults to 'no-reply' so every existing
11
+ -- row keeps today's behaviour unchanged.
12
+
13
+ alter table foundry_declared_orgs
14
+ add column if not exists owner_mailbox_address text,
15
+ add column if not exists no_reply_local_part text not null default 'no-reply';
@@ -0,0 +1,7 @@
1
+ -- @wtfalch/foundry: which ZITADEL apps in the org's audience project may
2
+ -- read a person's mail (#34). Mirrors `src/store/schema.ts`'s `mailApps`.
3
+ -- Defaults to 'agora' and 'webmail' so every existing org keeps its current
4
+ -- behaviour.
5
+
6
+ alter table foundry_declared_orgs
7
+ add column if not exists mail_apps jsonb not null default '["agora","webmail"]'::jsonb;
@@ -0,0 +1,6 @@
1
+ -- @wtfalch/foundry: the role mailboxes an org wants pre-created on its mail
2
+ -- domain (#230). Mirrors `src/store/schema.ts`'s `roleMailboxes`. Defaults to
3
+ -- an empty list so every existing org keeps its current behaviour.
4
+
5
+ alter table foundry_declared_orgs
6
+ add column if not exists role_mailboxes jsonb not null default '[]'::jsonb;
package/dist/org.d.ts CHANGED
@@ -32,11 +32,61 @@ export type MailMode = 'off' | 'received' | 'hosted';
32
32
  * Decided in #7 amendment 1: setting `domain` alone never starts a mail
33
33
  * change. `usesDomain` is mail's own field for that, independent of
34
34
  * `mode` — a domain can be recorded before mail is ever pointed at it.
35
+ *
36
+ * `mailApps`: which ZITADEL apps in the org's audience project may read a
37
+ * person's mail. Decided by William (2026-09-30, #34): only listed apps
38
+ * may read a person's mail, and the org's own declared config holds the
39
+ * list -- ZITADEL puts a project's own id into the default audience of
40
+ * every app signed in to that project, so a matching `requireAudience`
41
+ * value alone says nothing about *who* carries it (`manage` had exactly
42
+ * this hole until it moved to its own project, wtfalch/agora#100;
43
+ * `operator` has it today, wtfalch/operator#11). `undefined` means the org
44
+ * declares none of its own and {@link DEFAULT_MAIL_APPS} applies --
45
+ * optional so every existing declared org, and every test fixture that
46
+ * builds a `MailConfig` literal, keeps working unchanged; a caller that
47
+ * cares reads `mail.mailApps ?? DEFAULT_MAIL_APPS`, the same fallback
48
+ * `plan/mail-identity-allowlist.ts`'s own doc comment names.
35
49
  */
36
50
  export interface MailConfig {
37
51
  mode: MailMode;
38
52
  usesDomain: boolean;
53
+ mailApps?: string[];
54
+ /**
55
+ * The owner's mailbox address on this org's mail domain. Decided
56
+ * 2026-09-29 on #162: nothing is guessed any more, replacing the
57
+ * hardcoded `owner@<domain>` PR 163 shipped first. `null` (the default)
58
+ * until an operator names one -- `plan/diff.ts`'s `planMail` then reports
59
+ * the owner's mailbox alone as blocked, naming this field, while every
60
+ * other part of the org's mail still applies. Optional so a caller that
61
+ * builds a `MailConfig` for anything other than the owner's mailbox (most
62
+ * existing call sites, before this field existed) is unaffected; absent
63
+ * is treated the same as `null` everywhere this is read.
64
+ */
65
+ ownerMailboxAddress?: string | null;
66
+ /**
67
+ * The local part of the org's send-only no-reply address (no inbox),
68
+ * read by `plan/executor.ts`'s `ensureAppLettermintMail` for `MAIL_FROM`.
69
+ * Defaults to `'no-reply'` when absent -- #162: a localised default via
70
+ * `@wtfalch/i18n` (a Norwegian company's own word for it, say) is
71
+ * deferred, since that package has none yet. An operator's own value
72
+ * always wins once set.
73
+ */
74
+ noReplyLocalPart?: string;
75
+ /**
76
+ * Local parts of the role mailboxes this org wants pre-created on its mail
77
+ * domain (#230) -- `postmaster`, `abuse`, `security` and the like. Role
78
+ * mailboxes have no person behind them, so unlike {@link ownerMailboxAddress}
79
+ * they are not tied to `owner` and are never invited through the broker.
80
+ * Foundry holds no default set: every org declares its own. `undefined` or
81
+ * empty means none. Each name is a local part, not an address, so the
82
+ * address is always on the org's own resolved mail domain.
83
+ */
84
+ roleMailboxes?: string[];
39
85
  }
86
+ /** {@link MailConfig.mailApps} when the org declares none of its own (#34, decided 2026-09-30). */
87
+ export declare const DEFAULT_MAIL_APPS: readonly string[];
88
+ /** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
89
+ export declare const DEFAULT_NO_REPLY_LOCAL_PART = "no-reply";
40
90
  /**
41
91
  * Chat's per-service opt-in into the org's `domain` fact.
42
92
  *
@@ -329,6 +379,37 @@ export interface OwnerIdentity {
329
379
  * null` (no owner named yet) is always valid.
330
380
  */
331
381
  export declare function validateOwner(owner: OwnerIdentity | null): string | null;
382
+ /**
383
+ * `null` when `mail` is valid; otherwise the reason it is not (#162,
384
+ * decided 2026-09-29).
385
+ *
386
+ * `ownerMailboxAddress`, when set, must be a valid email address; when
387
+ * `usesDomain` is also true and `domain` is set, it must be on that exact
388
+ * domain ("validate it is on the org's own domain", #162's "How"). A
389
+ * level-1 org's mail domain is `<slug>.<platform apex>` -- the platform's
390
+ * own fact, not this file's -- so that case is left for `plan/diff.ts`'s
391
+ * `planMail` to resolve and block on, the same way it already resolves
392
+ * `mailHost` itself.
393
+ *
394
+ * Deliberately NOT required just because `mode` is not `'off'`: an org can
395
+ * have mail on with no address declared yet, same as it can have an owner
396
+ * named with no ZITADEL invite sent yet -- `planMail` reports the mailbox
397
+ * alone as blocked in that case rather than this function refusing to let
398
+ * the org be saved at all (#162 "Done when": "mail on with no owner address
399
+ * plans a blocked item and still applies the other steps").
400
+ *
401
+ * `noReplyLocalPart`, when set, must be non-empty and contain neither `@`
402
+ * nor whitespace.
403
+ *
404
+ * `roleMailboxes`, when set (#230), has only plain local parts (no `@`, `,` or
405
+ * whitespace) and no duplicate, ignoring case.
406
+ *
407
+ * `mailApps`, when set (#34), has no empty entry and no duplicate -- same
408
+ * two rules `validateHosting` already applies to `issuerApps`' names, for
409
+ * the same reason: a duplicate or blank entry can never mean anything
410
+ * different from leaving it out.
411
+ */
412
+ export declare function validateMail(org: Pick<DeclaredOrg, 'mail' | 'domain'>): string | null;
332
413
  /**
333
414
  * The form an app foundry does not own takes. Decided in #7's `#15`
334
415
  * refinement comment:
package/dist/org.js CHANGED
@@ -1,3 +1,7 @@
1
+ /** {@link MailConfig.mailApps} when the org declares none of its own (#34, decided 2026-09-30). */
2
+ export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
3
+ /** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
4
+ export const DEFAULT_NO_REPLY_LOCAL_PART = 'no-reply';
1
5
  const ISSUER_APP_KINDS = new Set(['web', 'native', 'api']);
2
6
  const HOSTING_BUILD_PACKS = new Set([
3
7
  'dockerfile',
@@ -49,6 +53,14 @@ export function validateHosting(hosting) {
49
53
  if (!ISSUER_APP_KINDS.has(app.kind)) {
50
54
  return `issuerApps '${app.name}' has an unknown kind '${app.kind}'`;
51
55
  }
56
+ // The fixed 'agora' ZITADEL client (#164, `plan/diff.ts`'s
57
+ // `planAgoraClient`) already owns the `zitadel:app:agora` key, for
58
+ // every org -- Agora is always on, unlike the cms client's own 'cms'
59
+ // reservation (`validateSite` below), which only applies once
60
+ // `site.enabled` is true.
61
+ if (app.name === 'agora') {
62
+ return `issuerApps '${app.name}' is reserved for the always-on Agora client every org gets`;
63
+ }
52
64
  }
53
65
  return null;
54
66
  }
@@ -177,3 +189,87 @@ export function validateOwner(owner) {
177
189
  return 'owner.familyName must not be empty';
178
190
  return null;
179
191
  }
192
+ /** A local part has no `@` and no whitespace -- loose on purpose, the same restraint {@link OWNER_EMAIL_PATTERN}'s own doc comment states. */
193
+ const LOCAL_PART_PATTERN = /^[^\s@]+$/;
194
+ /**
195
+ * `null` when `mail` is valid; otherwise the reason it is not (#162,
196
+ * decided 2026-09-29).
197
+ *
198
+ * `ownerMailboxAddress`, when set, must be a valid email address; when
199
+ * `usesDomain` is also true and `domain` is set, it must be on that exact
200
+ * domain ("validate it is on the org's own domain", #162's "How"). A
201
+ * level-1 org's mail domain is `<slug>.<platform apex>` -- the platform's
202
+ * own fact, not this file's -- so that case is left for `plan/diff.ts`'s
203
+ * `planMail` to resolve and block on, the same way it already resolves
204
+ * `mailHost` itself.
205
+ *
206
+ * Deliberately NOT required just because `mode` is not `'off'`: an org can
207
+ * have mail on with no address declared yet, same as it can have an owner
208
+ * named with no ZITADEL invite sent yet -- `planMail` reports the mailbox
209
+ * alone as blocked in that case rather than this function refusing to let
210
+ * the org be saved at all (#162 "Done when": "mail on with no owner address
211
+ * plans a blocked item and still applies the other steps").
212
+ *
213
+ * `noReplyLocalPart`, when set, must be non-empty and contain neither `@`
214
+ * nor whitespace.
215
+ *
216
+ * `roleMailboxes`, when set (#230), has only plain local parts (no `@`, `,` or
217
+ * whitespace) and no duplicate, ignoring case.
218
+ *
219
+ * `mailApps`, when set (#34), has no empty entry and no duplicate -- same
220
+ * two rules `validateHosting` already applies to `issuerApps`' names, for
221
+ * the same reason: a duplicate or blank entry can never mean anything
222
+ * different from leaving it out.
223
+ */
224
+ export function validateMail(org) {
225
+ const { mail } = org;
226
+ const address = mail.ownerMailboxAddress ?? null;
227
+ if (address !== null) {
228
+ if (!OWNER_EMAIL_PATTERN.test(address)) {
229
+ return `mail.ownerMailboxAddress '${address}' is not a valid email address`;
230
+ }
231
+ if (mail.usesDomain && org.domain) {
232
+ const addressDomain = address.slice(address.indexOf('@') + 1).toLowerCase();
233
+ if (addressDomain !== org.domain.toLowerCase()) {
234
+ return `mail.ownerMailboxAddress '${address}' must be on ${org.domain}`;
235
+ }
236
+ }
237
+ }
238
+ if (mail.noReplyLocalPart !== undefined && !LOCAL_PART_PATTERN.test(mail.noReplyLocalPart)) {
239
+ return `mail.noReplyLocalPart '${mail.noReplyLocalPart}' must not be empty or contain '@' or whitespace`;
240
+ }
241
+ if (mail.roleMailboxes) {
242
+ const seen = new Set();
243
+ const reserved = new Map([
244
+ [(mail.noReplyLocalPart ?? DEFAULT_NO_REPLY_LOCAL_PART).toLowerCase(), 'no-reply'],
245
+ ]);
246
+ if (address !== null)
247
+ reserved.set(address.slice(0, address.indexOf('@')).toLowerCase(), 'owner');
248
+ for (const name of mail.roleMailboxes) {
249
+ if (!LOCAL_PART_PATTERN.test(name) || name.includes(',')) {
250
+ return `mail.roleMailboxes entry '${name}' must be a non-empty local part without '@', ',' or whitespace`;
251
+ }
252
+ if (seen.has(name.toLowerCase())) {
253
+ return `mail.roleMailboxes name '${name}' is not unique`;
254
+ }
255
+ const clash = reserved.get(name.toLowerCase());
256
+ if (clash) {
257
+ return `mail.roleMailboxes name '${name}' clashes with the ${clash} mailbox`;
258
+ }
259
+ seen.add(name.toLowerCase());
260
+ }
261
+ }
262
+ if (mail.mailApps) {
263
+ const seen = new Set();
264
+ for (const name of mail.mailApps) {
265
+ if (!name) {
266
+ return 'mail.mailApps entry needs a non-empty name';
267
+ }
268
+ if (seen.has(name)) {
269
+ return `mail.mailApps name '${name}' is not unique`;
270
+ }
271
+ seen.add(name);
272
+ }
273
+ }
274
+ return null;
275
+ }
@@ -0,0 +1,77 @@
1
+ import type { DeclaredOrg, OrgId } from '../org.js';
2
+ /**
3
+ * #164: what both the planner (`diff.ts`) and the executor (`executor.ts`)
4
+ * need to agree on for an org's Agora -- the hostname, the values the host
5
+ * supplies, and the env vars those produce -- so the plan can compare the
6
+ * live app with exactly what the executor would write.
7
+ */
8
+ /**
9
+ * `AppliedState.serviceIds` scope keys `desiredAgoraEnv` reads:
10
+ * `zitadel_org` is `executor.ts`'s `ensureZitadelProject`/`zitadelIssuer`
11
+ * scope id, `agora_client_id` the fixed `agora` ZITADEL client's `clientId`
12
+ * (recorded by `executor.ts`'s `zitadelApp`).
13
+ */
14
+ export declare const ZITADEL_ORG_SCOPE_KEY = "zitadel_org";
15
+ export declare const AGORA_CLIENT_ID_SCOPE_KEY = "agora_client_id";
16
+ /**
17
+ * The accent every org's Agora wears until orgs choose their own: the house
18
+ * theme's `--accent` (`@wtfalch/house`, `HOUSE_THEMES['good-company']` in
19
+ * `design/packages/house/src/themes.ts`, the default theme
20
+ * `HOUSE_DEFAULT_THEME`). Agora's config parser requires `theme.accent` as a
21
+ * hex colour (`agora/apps/agora/src/agoras.ts`). Restated here rather than
22
+ * imported: `packages/foundry` carries no `@wtfalch/house` dependency.
23
+ */
24
+ export declare const AGORA_THEME_ACCENT = "#B23A0C";
25
+ /**
26
+ * Provider-side values only the host can supply, like every other provider
27
+ * value `FoundryStepExecutorPorts` carries. As of #177, `plan-apply.ts`'s
28
+ * `FoundryPlanApply` derives these itself from the applied platform
29
+ * (`platform-org.ts`'s `agoraHostValuesFromPlatform`) whenever a host wires
30
+ * one in and supplies no `AgoraHostPort` of its own -- an explicit `agora`
31
+ * port still overrides that, unchanged. `null` means the value is not
32
+ * available yet. The first three are required by Agora's config parser
33
+ * (`agoras.ts`: `issuer`, `mailProjectId`, `messagingProjectId`);
34
+ * `mailUpstream`/`filesUpstream` are the mail/files servers' internal
35
+ * addresses, optional -- unset, `AGORA_MAIL_UPSTREAM`/`AGORA_FILES_UPSTREAM`
36
+ * are not pushed and Agora answers 502 on those routes rather than serving
37
+ * anything wrong (`executor.ts`'s `coolifyAgora` doc comment).
38
+ */
39
+ export interface AgoraHostValues {
40
+ issuer: string | null;
41
+ mailProjectId: string | null;
42
+ messagingProjectId: string | null;
43
+ mailUpstream: string | null;
44
+ filesUpstream: string | null;
45
+ }
46
+ export interface AgoraHostPort {
47
+ valuesFor(orgId: OrgId): Promise<AgoraHostValues>;
48
+ }
49
+ /** No host values at all: what an absent port means. */
50
+ export declare const NO_AGORA_HOST_VALUES: AgoraHostValues;
51
+ /** What the plan reads from the host: its values, or why it could not get them. */
52
+ export type AgoraHostReading = AgoraHostValues | {
53
+ error: string;
54
+ };
55
+ /** Reads the port once for a plan. An absent port, or one that throws, is a blocked Agora, never a failed plan. */
56
+ export declare function readAgoraHost(port: AgoraHostPort | undefined, orgId: OrgId): Promise<AgoraHostReading>;
57
+ /** The host values Agora cannot be configured without, by name -- the ones `host` lacks. */
58
+ export declare function agoraHostMissing(host: AgoraHostValues): string[];
59
+ /**
60
+ * `agora.<domain>` when the org has a domain, `<slug>-agora.oikos.host`
61
+ * otherwise (the 2026-09-24 layout, foundry#14; `../slug.ts`'s
62
+ * `AGORA_SUFFIX` reserves the collision).
63
+ */
64
+ export declare function agoraHostnameFor(org: DeclaredOrg): string;
65
+ /**
66
+ * The env vars an org's Agora app should carry, and every input still
67
+ * missing to build them. `AGORA_CONFIG` is present only when `missing` is
68
+ * empty; callers that write must refuse on a non-empty `missing`.
69
+ * `AGORA_MAIL_UPSTREAM` is present only when mail is on and the host supplies
70
+ * an address.
71
+ */
72
+ export declare function desiredAgoraEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, host: AgoraHostValues): {
73
+ env: Record<string, string>;
74
+ missing: string[];
75
+ };
76
+ /** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
77
+ export declare function agoraEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];