@wtfalch/foundry 0.76.0 → 0.77.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 (127) hide show
  1. package/dist/app-canary-worker.d.ts +35 -0
  2. package/dist/app-canary-worker.js +56 -0
  3. package/dist/audit/connector.d.ts +6 -2
  4. package/dist/audit/connector.js +3 -2
  5. package/dist/audit/evidence.d.ts +4 -0
  6. package/dist/audit/evidence.js +14 -2
  7. package/dist/audit/host-store.d.ts +58 -0
  8. package/dist/audit/host-store.js +147 -0
  9. package/dist/audit/host.d.ts +53 -0
  10. package/dist/audit/host.js +294 -0
  11. package/dist/audit/index.d.ts +2 -0
  12. package/dist/audit/index.js +2 -0
  13. package/dist/audit/registry.d.ts +14 -0
  14. package/dist/audit/sealer-role.d.ts +12 -0
  15. package/dist/audit/sealer-role.js +26 -2
  16. package/dist/audit/tick.d.ts +10 -2
  17. package/dist/audit/tick.js +15 -2
  18. package/dist/catalogue/copy-en.js +5 -0
  19. package/dist/catalogue/integrations.js +5 -0
  20. package/dist/entry-rules.d.ts +35 -0
  21. package/dist/entry-rules.js +35 -0
  22. package/dist/hosted-label.js +2 -1
  23. package/dist/index.d.ts +11 -3
  24. package/dist/index.js +6 -1
  25. package/dist/migrations/0072_audit_host.sql +46 -0
  26. package/dist/migrations/0074_platform_app_canary.sql +11 -0
  27. package/dist/modules/ai.js +15 -1
  28. package/dist/modules/assistants.js +3 -0
  29. package/dist/modules/email.d.ts +6 -1
  30. package/dist/modules/email.js +88 -5
  31. package/dist/modules/files.js +79 -2
  32. package/dist/modules/index.js +7 -0
  33. package/dist/modules/jobs.d.ts +5 -1
  34. package/dist/modules/jobs.js +74 -4
  35. package/dist/modules/reporting.d.ts +9 -0
  36. package/dist/modules/reporting.js +29 -0
  37. package/dist/modules/sign-in.js +20 -2
  38. package/dist/modules/types.d.ts +2 -0
  39. package/dist/modules/workspaces.js +3 -0
  40. package/dist/org.d.ts +114 -13
  41. package/dist/org.js +233 -20
  42. package/dist/plan/ai-boule-key-apply.d.ts +65 -0
  43. package/dist/plan/ai-boule-key-apply.js +169 -0
  44. package/dist/plan/ai-boule-key.d.ts +89 -0
  45. package/dist/plan/ai-boule-key.js +195 -0
  46. package/dist/plan/analytics.d.ts +8 -1
  47. package/dist/plan/analytics.js +8 -1
  48. package/dist/plan/app-canary-apply.d.ts +49 -0
  49. package/dist/plan/app-canary-apply.js +232 -0
  50. package/dist/plan/app-canary.d.ts +44 -0
  51. package/dist/plan/app-canary.js +110 -0
  52. package/dist/plan/blue-green-apply.d.ts +6 -2
  53. package/dist/plan/blue-green-apply.js +14 -3
  54. package/dist/plan/broker-entries.js +20 -5
  55. package/dist/plan/company-backups.js +2 -0
  56. package/dist/plan/desktop-auth.d.ts +17 -4
  57. package/dist/plan/desktop-auth.js +22 -5
  58. package/dist/plan/diff.d.ts +22 -0
  59. package/dist/plan/diff.js +231 -18
  60. package/dist/plan/entry-files-key.d.ts +39 -3
  61. package/dist/plan/entry-files-key.js +103 -6
  62. package/dist/plan/entry-jobs-boule-key-apply.d.ts +65 -0
  63. package/dist/plan/entry-jobs-boule-key-apply.js +192 -0
  64. package/dist/plan/entry-jobs-boule-key.d.ts +59 -0
  65. package/dist/plan/entry-jobs-boule-key.js +141 -0
  66. package/dist/plan/entry-jobs.d.ts +2 -0
  67. package/dist/plan/entry-reporting-key.d.ts +36 -0
  68. package/dist/plan/entry-reporting-key.js +136 -0
  69. package/dist/plan/executor.d.ts +108 -1
  70. package/dist/plan/executor.js +676 -24
  71. package/dist/plan/files-boule-key-apply.d.ts +73 -0
  72. package/dist/plan/files-boule-key-apply.js +223 -0
  73. package/dist/plan/files-boule-key.d.ts +92 -0
  74. package/dist/plan/files-boule-key.js +175 -0
  75. package/dist/plan/hostnames.js +4 -0
  76. package/dist/plan/index.d.ts +4 -0
  77. package/dist/plan/index.js +2 -0
  78. package/dist/plan/jobs-scheduler-http.d.ts +13 -0
  79. package/dist/plan/jobs-scheduler-http.js +39 -0
  80. package/dist/plan/live.d.ts +1 -1
  81. package/dist/plan/locked-hostnames.d.ts +1 -1
  82. package/dist/plan/locked-hostnames.js +4 -1
  83. package/dist/plan/login-host.js +2 -2
  84. package/dist/plan/org-names.js +3 -0
  85. package/dist/plan/own-diff.js +3 -2
  86. package/dist/plan/own-jobs-route.d.ts +5 -2
  87. package/dist/plan/own-jobs-route.js +6 -2
  88. package/dist/plan/own-limits.d.ts +6 -0
  89. package/dist/plan/own-limits.js +1 -0
  90. package/dist/plan/plan-apply.d.ts +24 -0
  91. package/dist/plan/plan-apply.js +39 -4
  92. package/dist/plan/platform-diff.d.ts +2 -0
  93. package/dist/plan/platform-diff.js +15 -1
  94. package/dist/plan/platform-providers.d.ts +9 -0
  95. package/dist/plan/platform-providers.js +21 -0
  96. package/dist/plan/providers-readers.js +25 -1
  97. package/dist/plan/registered-app-env.d.ts +31 -0
  98. package/dist/plan/registered-app-env.js +63 -0
  99. package/dist/plan/registered-app.d.ts +121 -0
  100. package/dist/plan/registered-app.js +262 -0
  101. package/dist/plan/release-host.js +2 -1
  102. package/dist/plan/reporting-boule.d.ts +18 -0
  103. package/dist/plan/reporting-boule.js +44 -0
  104. package/dist/plan/reporting.d.ts +90 -0
  105. package/dist/plan/reporting.js +135 -0
  106. package/dist/plan/stage-env.js +1 -0
  107. package/dist/plan/stage-isolation.d.ts +3 -1
  108. package/dist/plan/stage-isolation.js +20 -2
  109. package/dist/plan/stage-services.d.ts +3 -1
  110. package/dist/plan/stage-services.js +8 -1
  111. package/dist/plan/stage-sink.d.ts +2 -2
  112. package/dist/plan/stage-sink.js +2 -2
  113. package/dist/plan/workspace-client.d.ts +1 -1
  114. package/dist/plan/workspace-client.js +2 -1
  115. package/dist/plan/workspace-sign-in.d.ts +1 -1
  116. package/dist/plan/workspace-sign-in.js +39 -7
  117. package/dist/platform.d.ts +25 -0
  118. package/dist/platform.js +20 -0
  119. package/dist/service-labels.d.ts +8 -0
  120. package/dist/service-labels.js +8 -0
  121. package/dist/slug.d.ts +1 -1
  122. package/dist/slug.js +5 -2
  123. package/dist/store/postgres.js +13 -0
  124. package/dist/store/schema.d.ts +485 -2
  125. package/dist/store/schema.js +38 -1
  126. package/dist/worker-app-config.js +2 -2
  127. package/package.json +1 -1
package/dist/org.d.ts CHANGED
@@ -301,7 +301,7 @@ export type AccessRule = {
301
301
  /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
302
302
  export declare const APP_ENTRY_KEY = "app";
303
303
  /** Why a validator refused. `code` is stable and never reworded; `message` is for people. */
304
- export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'issuer_app_name_reserved_kosmos' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'entry_image_invalid' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'hosting_repo_invalid' | 'hosting_github_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'hosted_name_invalid' | 'hosted_name_not_unique' | 'hosted_name_worker_app' | 'entry_workspace_invalid' | 'entry_workspace_unsupported' | 'entry_live_needs_workspace' | 'entry_live_commit_invalid' | 'gate_key_taken_by_workspace' | 'gate_key_taken_by_private_page' | 'private_page_invalid' | 'private_page_path_invalid' | 'private_page_duplicate' | 'private_page_limit' | 'private_page_needs_hosting' | 'private_page_roles_invalid' | 'private_page_people_invalid' | 'entry_key_invalid' | 'entry_key_reserved' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'builtin_app_invalid' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'blue_green_advance_invalid' | 'blue_green_min_requests_invalid' | 'blue_green_error_margin_invalid' | 'blue_green_staff_first_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'mcp_events_path_invalid' | 'mcp_manage_path_invalid' | 'auth_sign_in_invalid' | 'auth_sign_ins_invalid' | 'auth_second_factor_invalid' | 'sign_in_staff_password_only' | 'passkey_needs_another_method' | 'email_code_needs_customers' | 'email_code_needs_sign_in' | 'auth_room_invalid' | 'auth_room_needs_customers' | 'auth_room_not_unique' | 'client_name_taken' | 'workspaces_needs_customers' | 'workspaces_needs_server' | 'workspaces_roles_invalid' | 'workspaces_role_reserved' | 'workspaces_invite_path_invalid' | 'workspaces_env_clash' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_needs_own_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'mail_not_true' | 'mail_needs_hosting' | 'mail_env_clash' | 'jobs_not_true' | 'jobs_needs_hosting' | 'jobs_env_clash' | 'analytics_not_true' | 'analytics_needs_hosting' | 'analytics_env_clash' | 'customer_grants_not_true' | 'customer_grants_needs_customers' | 'customer_grants_env_clash' | 'repo_use_invalid' | 'repo_use_needs_repo' | 'repo_use_env_clash' | 'audit_not_true' | 'audit_needs_hosting' | 'audit_needs_worker_database' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
304
+ export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'issuer_app_name_reserved_kosmos' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'entry_image_invalid' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'hosting_repo_invalid' | 'hosting_github_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'hosted_name_invalid' | 'hosted_name_not_unique' | 'hosted_name_worker_app' | 'entry_workspace_invalid' | 'entry_workspace_unsupported' | 'entry_live_needs_workspace' | 'entry_live_commit_invalid' | 'gate_key_taken_by_workspace' | 'gate_key_taken_by_private_page' | 'private_page_invalid' | 'private_page_path_invalid' | 'private_page_duplicate' | 'private_page_limit' | 'private_page_needs_hosting' | 'private_page_roles_invalid' | 'private_page_people_invalid' | 'entry_key_invalid' | 'entry_key_reserved' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'builtin_app_invalid' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'blue_green_advance_invalid' | 'blue_green_min_requests_invalid' | 'blue_green_error_margin_invalid' | 'blue_green_staff_first_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_both_needs_customers' | 'auth_both_worker_app' | 'auth_both_unsupported' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'mcp_events_path_invalid' | 'mcp_manage_path_invalid' | 'auth_sign_in_invalid' | 'auth_sign_ins_invalid' | 'auth_second_factor_invalid' | 'sign_in_staff_password_only' | 'passkey_needs_another_method' | 'email_code_needs_customers' | 'email_code_needs_sign_in' | 'auth_room_invalid' | 'auth_room_needs_customers' | 'auth_room_not_unique' | 'client_name_taken' | 'workspaces_needs_customers' | 'workspaces_needs_server' | 'workspaces_roles_invalid' | 'workspaces_role_reserved' | 'workspaces_invite_path_invalid' | 'workspaces_env_clash' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'files_settings_invalid' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_needs_own_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'mail_not_true' | 'mail_needs_hosting' | 'mail_env_clash' | 'mail_settings_invalid' | 'jobs_not_true' | 'jobs_needs_hosting' | 'jobs_env_clash' | 'jobs_settings_invalid' | 'analytics_not_true' | 'analytics_needs_hosting' | 'analytics_env_clash' | 'reporting_not_true' | 'reporting_needs_hosting' | 'reporting_env_clash' | 'reporting_site_invalid' | 'customer_grants_not_true' | 'customer_grants_needs_customers' | 'customer_grants_env_clash' | 'repo_use_invalid' | 'repo_use_needs_repo' | 'repo_use_env_clash' | 'audit_not_true' | 'audit_needs_hosting' | 'audit_needs_worker_database' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
305
305
  /**
306
306
  * One refusal: a stable `code`, the dotted `path` into {@link DeclaredOrg} it is about
307
307
  * (`hosting.entries.terminal.uses.auth.registration`) and the sentence for people.
@@ -604,15 +604,22 @@ export interface WorkerConfig {
604
604
  route: string;
605
605
  bindings?: WorkerBindings;
606
606
  }
607
- /**
608
- * Sign-in for one entry: one ZITADEL client and one broker entry, signing in
609
- * ONE organisation. `staff` is the org's own ZITADEL org, `customers` the
610
- * `<slug> customers` org. `registration` is the broker entry's sign-up switch.
611
- * `replacesBrokerEntry` is the `name` of the hand-written broker `AUTH_APPS`
612
- * entry this entry's own entry replaces; the first write removes it.
607
+ /** Who signs in to an app: `staff` is the company's own team, `customers` the people of its customers organisation. */
608
+ export type EntryAudience = 'staff' | 'customers';
609
+ export declare const ENTRY_AUDIENCES: readonly EntryAudience[];
610
+ /**
611
+ * Sign-in for one entry. With one audience (`users: 'staff'` or `'customers'`, or a list of that one): one
612
+ * ZITADEL client foundry makes and one broker `AUTH_APPS` entry, signing in ONE organisation. `staff` is the
613
+ * org's own ZITADEL org, `customers` the `<slug> customers` org. With both (`users: ['customers', 'staff']`,
614
+ * ADR 0003): the app is registered at the sign-in broker through the company's registrar
615
+ * (`plan/registered-app.ts`, auth ADR 0008), in the customers organisation, and the broker makes its two
616
+ * clients: the customers' and the team's, in the company's own organisation. `registration` is the broker
617
+ * entry's sign-up switch (for customers). `replacesBrokerEntry` is the `name` of the hand-written broker
618
+ * `AUTH_APPS` entry this entry's own entry replaces; the first write removes it. Read who signs in only
619
+ * through {@link entryAudiences}, {@link signsInCustomers} and {@link signsInBoth}.
613
620
  */
614
621
  export interface EntryAuth {
615
- users: 'staff' | 'customers';
622
+ users: EntryAudience | EntryAudience[];
616
623
  registration: 'open' | 'closed';
617
624
  replacesBrokerEntry?: string;
618
625
  /**
@@ -672,6 +679,16 @@ export interface EntryAuth {
672
679
  */
673
680
  room?: EntryAuthRoom;
674
681
  }
682
+ /**
683
+ * Who signs in, as a sorted list without repeats (`['customers']`, `['staff']` or `['customers', 'staff']`).
684
+ * A one-value list is the same as the value. An invalid `users` answers what it holds that is known, so call
685
+ * this on a validated org.
686
+ */
687
+ export declare function entryAudiences(auth: Pick<EntryAuth, 'users'>): EntryAudience[];
688
+ /** True when the entry's customers sign in (alone, or beside the team): the app lives in the customers organisation. */
689
+ export declare function signsInCustomers(auth: Pick<EntryAuth, 'users'> | undefined): boolean;
690
+ /** True when both the customers and the company's team sign in: the app is registered through the registrar. */
691
+ export declare function signsInBoth(auth: Pick<EntryAuth, 'users'> | undefined): boolean;
675
692
  export type SignInMethod = 'email-code' | 'password' | 'passkey';
676
693
  export declare const SIGN_IN_METHODS: readonly SignInMethod[];
677
694
  export type SecondFactorMode = 'off' | 'allowed' | 'required';
@@ -714,10 +731,66 @@ export interface EntryFiles {
714
731
  collections: string[];
715
732
  permissions?: string[];
716
733
  shareHost?: boolean;
734
+ /**
735
+ * #1116: how many days a new share link works, one of {@link FILES_SHARE_LINK_DAYS}, or `null` for links
736
+ * that never stop. Absent is {@link FILES_SHARE_LINK_DAYS_DEFAULT}. Delivered to the app as
737
+ * `FILES_SHARE_LINK_DAYS` (`never` for null) while it is not the default, for the app to pass to files as
738
+ * the link's `expiresAt`.
739
+ */
740
+ shareLinkDays?: number | null;
741
+ /**
742
+ * #1116: people may pick another time when they share a file. Absent is true. Delivered as
743
+ * `FILES_SHARE_LINK_PICK=false` while it is false.
744
+ */
745
+ shareLinkPick?: boolean;
746
+ /**
747
+ * #1116: the share of the space used, in percent, at which the company's owners are emailed. Absent is
748
+ * {@link FILES_SPACE_ALERT_DEFAULT}. For the files service, which does not read it yet; foundry delivers it
749
+ * to nothing.
750
+ */
751
+ spaceAlertPercent?: number;
717
752
  }
718
753
  /** The env names `uses.files` always writes, besides one `FILES_COLLECTION_<NAME>_ID` per collection. */
719
754
  export declare const FILES_ENTRY_ENV_NAMES: readonly ["FILES_URL", "FILES_ORGANISATION_ID", "FILES_API_KEY"];
720
755
  export declare const filesCollectionEnvName: (name: string) => string;
756
+ /**
757
+ * #1116: Email's settings. `fromName` and `replyTo` go to the app's env (`MAIL_FROM_NAME`, `MAIL_REPLY_TO`,
758
+ * only when set), for `@wtfalch/mailer`'s `fromName` and `replyTo`. The bounce settings are for the
759
+ * company's mail service, which does not read them yet; foundry delivers them to nothing. `uses.mail: true`
760
+ * is every setting at its default.
761
+ */
762
+ export interface EntryMail {
763
+ /** The name people see as the sender. Absent: the company's name, as today. */
764
+ fromName?: string;
765
+ /** Where replies go. Absent: to the sending address. */
766
+ replyTo?: string;
767
+ /** Stop sending to an address after this many bounces. Absent is {@link MAIL_BOUNCES_DEFAULT}. */
768
+ stopAfterBounces?: number;
769
+ /** Email the owners when more than this share of a week's mail bounces, in percent. Absent is {@link MAIL_BOUNCE_ALERT_DEFAULT}. */
770
+ bounceAlertPercent?: number;
771
+ }
772
+ /**
773
+ * #1116: Jobs' settings, for the jobs scheduler's project, which does not read them yet (jobs#79 has no
774
+ * project settings); foundry delivers them to nothing. `uses.jobs: true` is every setting at its default.
775
+ */
776
+ export interface EntryJobs {
777
+ /** How many times a failed job is tried again. Absent is {@link JOBS_RETRIES_DEFAULT}. */
778
+ retries?: number;
779
+ /** Email the owners when a job failed all its tries. Absent is true. */
780
+ alertOwners?: boolean;
781
+ /** Pause a job after this many failed runs in a row. Absent or null: never paused for failing. */
782
+ pauseAfterFailures?: number | null;
783
+ }
784
+ /** #1116: the env names the module settings write; `env` may not name one while its setting is set. */
785
+ export declare const FILES_SETTINGS_ENV_NAMES: readonly ["FILES_SHARE_LINK_DAYS", "FILES_SHARE_LINK_PICK"];
786
+ export declare const MAIL_SETTINGS_ENV_NAMES: readonly ["MAIL_FROM_NAME", "MAIL_REPLY_TO"];
787
+ /**
788
+ * #1116: the env an entry's module settings give the app. A name is written only while its setting differs
789
+ * from the default, so an entry saved before these settings plans as it did; the app reads an absent name as
790
+ * the default (`FILES_SHARE_LINK_DAYS` 7, `FILES_SHARE_LINK_PICK` true, no `MAIL_FROM_NAME` or
791
+ * `MAIL_REPLY_TO`). A name a setting stopped writing is taken out of the app's env by the next apply.
792
+ */
793
+ export declare function entrySettingsEnv(entry: HostingEntry): Record<string, string>;
721
794
  export interface HostingEntryUses {
722
795
  auth?: EntryAuth;
723
796
  ai?: EntryAi;
@@ -732,19 +805,22 @@ export interface HostingEntryUses {
732
805
  edge?: true;
733
806
  /**
734
807
  * The entry sends mail from the company's mail domain: it gets the company's
735
- * Lettermint token and `MAIL_FROM`, as the main app does. In a workspace the
736
- * stage gets the mail sink's env only (`stageMailEnv`).
808
+ * Lettermint token and `MAIL_FROM`, as the main app does, and `MAIL_APP_KEY`
809
+ * ({@link MAIL_APP_KEY_ENV}). In a workspace the stage gets the mail sink's
810
+ * env only (`stageMailEnv`) and `MAIL_APP_KEY` = `<key>-work`. An object carries the Email module's settings
811
+ * ({@link EntryMail}); `true` is all of them at their defaults.
737
812
  */
738
- mail?: true;
813
+ mail?: true | EntryMail;
739
814
  /**
740
815
  * The entry is a project of its own in the jobs scheduler and gets
741
816
  * `JOBS_SCHEDULER_URL` (the scheduler's public origin), `JOBS_SIGNING_SECRET`
742
817
  * and `JOBS_SYNC_KEY` (`plan/entry-jobs.ts`). Blocked, not planned, until the
743
818
  * scheduler's public route (`plan/own-jobs-route.ts`) is recorded: the owner
744
819
  * company's for a company on shared services, the company's own otherwise.
745
- * A workspace gets none of the three.
820
+ * A workspace gets none of the three. An object carries the Jobs module's settings ({@link EntryJobs});
821
+ * `true` is all of them at their defaults.
746
822
  */
747
- jobs?: true;
823
+ jobs?: true | EntryJobs;
748
824
  /**
749
825
  * The app keeps a tamper-evident audit log (`@wtfalch/audit`). A Worker app with a database: the append-only
750
826
  * tables and the two chain functions are derived (`audit/ledger-set.ts`), never listed by hand, and the
@@ -772,13 +848,38 @@ export interface HostingEntryUses {
772
848
  * app gets its keys from Boule, which installs them per copy, so foundry only plans the backend for it.
773
849
  */
774
850
  analytics?: true;
851
+ /**
852
+ * The app sends its errors to the company's own reporting backend, and Boule shows them. A server entry gets
853
+ * its own site key (`REPORTING_URL`, `REPORTING_SITE_KEY`; `plan/entry-reporting-key.ts`) with site = the
854
+ * entry key, and its stage another (environment `stage`). A Worker app gets its keys from Boule, which installs
855
+ * them per copy, so foundry only plans the backend for it. The entry key must be a site the backend accepts
856
+ * (`reporting_site_invalid`).
857
+ */
858
+ reporting?: true;
775
859
  }
776
860
  /** The env names `uses.edge` writes; `env` may not name them (#317). */
777
861
  export declare const EDGE_ENTRY_ENV_NAMES: readonly ["EDGE_URL", "EDGE_API_KEY"];
778
862
  /** The env names `uses.analytics` writes on a server entry; `env` may not name them. */
779
863
  export declare const ANALYTICS_ENTRY_ENV_NAMES: readonly ["ANALYTICS_URL", "ANALYTICS_SITE_KEY"];
864
+ /**
865
+ * What the reporting backend accepts as a site (`SITE_PATTERN` in wtfalch/reporting's core): a lower-case
866
+ * letter, then letters, digits and hyphens, at most 64 in all. An entry's site is its key.
867
+ */
868
+ export declare const REPORTING_SITE_PATTERN: RegExp;
869
+ /** The env names `uses.reporting` writes on a server entry; `env` may not name them. */
870
+ export declare const REPORTING_ENTRY_ENV_NAMES: readonly ["REPORTING_URL", "REPORTING_SITE_KEY"];
780
871
  /** The env names `uses.mail` writes; `env` may not name them. */
781
872
  export declare const MAIL_ENTRY_ENV_NAMES: readonly ["LETTERMINT_API_TOKEN", "MAIL_FROM"];
873
+ /**
874
+ * The env name every entry that sends mail ({@link sendsMail}) gets, holding its entry key, and its
875
+ * stage or workspace copy `<key>-work` ({@link mailAppKeyOf}). `@wtfalch/mailer` 0.10.0 tags each send
876
+ * with it, so Archon's mail reads can split mail per app. Not a secret. No entry's `env` may name it.
877
+ */
878
+ export declare const MAIL_APP_KEY_ENV = "MAIL_APP_KEY";
879
+ /** Whether the entry sends mail: the main entry and each entry with `uses.mail`. */
880
+ export declare function sendsMail(entry: Pick<HostingEntry, 'key' | 'main' | 'uses'>): boolean;
881
+ /** The `MAIL_APP_KEY` of an entry's copy: its key live, `<key>-work` for its stage or workspace. */
882
+ export declare function mailAppKeyOf(entryKey: string, copy: 'live' | 'work'): string;
782
883
  /** The env names `uses.jobs` writes; `env` may not name them. */
783
884
  export declare const JOBS_ENTRY_ENV_NAMES: readonly ["JOBS_SCHEDULER_URL", "JOBS_SIGNING_SECRET", "JOBS_SYNC_KEY"];
784
885
  /**
package/dist/org.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_RULES, checkName, explain } from '@wtfalch/auth/names';
2
2
  import { floatingRefBlocked, floatingTagBlocked } from './bootstrap/versions.js';
3
- import { AI_ENTRY_PERMISSIONS, AI_WEBHOOK_PATH, FILES_COLLECTION_NAME, FILES_ENTRY_PERMISSIONS, MCP_PATH, ONBOARDING_PATH_MAX_LENGTH, ROOM_STEPS_MAX, ROOM_STEPS_MIN, ROOM_TEXT_MAX, ROOM_UNSAFE_TEXT, WORKSPACE_ROLE, WORKSPACE_ROLES_MAX, isEdgePath, isPagePath, } from './entry-rules.js';
3
+ import { AI_ENTRY_PERMISSIONS, AI_WEBHOOK_PATH, FILES_COLLECTION_NAME, FILES_ENTRY_PERMISSIONS, FILES_SHARE_LINK_DAYS, FILES_SHARE_LINK_DAYS_DEFAULT, JOBS_PAUSE_AFTER_MAX, JOBS_RETRIES_MAX, MAIL_BOUNCES_MAX, MAIL_FROM_NAME, MAIL_FROM_NAME_MAX, MAIL_REPLY_TO, MAIL_REPLY_TO_MAX, MCP_PATH, ONBOARDING_PATH_MAX_LENGTH, PERCENT_MAX, PERCENT_MIN, ROOM_STEPS_MAX, ROOM_STEPS_MIN, ROOM_TEXT_MAX, ROOM_UNSAFE_TEXT, WORKSPACE_ROLE, WORKSPACE_ROLES_MAX, isEdgePath, isPagePath, wholeIn, } from './entry-rules.js';
4
4
  import { hostedLabelRefusal } from './hosted-label.js';
5
5
  import { entryHasDatabase, validateArtifact, validateEntryWorkerApp, } from './worker-app-config.js';
6
6
  export { AI_ENTRY_PERMISSIONS, FILES_ENTRY_PERMISSIONS, MCP_PATH, ONBOARDING_PATH_MAX_LENGTH, ROOM_STEPS_MAX, ROOM_STEPS_MIN, ROOM_TEXT_MAX, WORKSPACE_ROLE, WORKSPACE_ROLES_MAX, } from './entry-rules.js';
@@ -55,6 +55,24 @@ export function withNewOrgAppsDefault(org) {
55
55
  export const kosmosOn = (org) => org.kosmos?.enabled === true;
56
56
  /** The most volumes one entry may declare. */
57
57
  export const MAX_ENTRY_VOLUMES = 8;
58
+ export const ENTRY_AUDIENCES = ['customers', 'staff'];
59
+ /**
60
+ * Who signs in, as a sorted list without repeats (`['customers']`, `['staff']` or `['customers', 'staff']`).
61
+ * A one-value list is the same as the value. An invalid `users` answers what it holds that is known, so call
62
+ * this on a validated org.
63
+ */
64
+ export function entryAudiences(auth) {
65
+ const raw = Array.isArray(auth.users) ? auth.users : [auth.users];
66
+ return ENTRY_AUDIENCES.filter((audience) => raw.includes(audience));
67
+ }
68
+ /** True when the entry's customers sign in (alone, or beside the team): the app lives in the customers organisation. */
69
+ export function signsInCustomers(auth) {
70
+ return auth !== undefined && entryAudiences(auth).includes('customers');
71
+ }
72
+ /** True when both the customers and the company's team sign in: the app is registered through the registrar. */
73
+ export function signsInBoth(auth) {
74
+ return auth !== undefined && entryAudiences(auth).length === 2;
75
+ }
58
76
  export const SIGN_IN_METHODS = ['email-code', 'password', 'passkey'];
59
77
  export const SECOND_FACTOR_MODES = ['off', 'allowed', 'required'];
60
78
  /** The env names `uses.auth.workspaces` writes; `env` may not name them. */
@@ -74,12 +92,63 @@ export const FILES_ENTRY_ENV_NAMES = [
74
92
  'FILES_API_KEY',
75
93
  ];
76
94
  export const filesCollectionEnvName = (name) => `FILES_COLLECTION_${name}_ID`;
95
+ /** #1116: the env names the module settings write; `env` may not name one while its setting is set. */
96
+ export const FILES_SETTINGS_ENV_NAMES = ['FILES_SHARE_LINK_DAYS', 'FILES_SHARE_LINK_PICK'];
97
+ export const MAIL_SETTINGS_ENV_NAMES = ['MAIL_FROM_NAME', 'MAIL_REPLY_TO'];
98
+ /**
99
+ * #1116: the env an entry's module settings give the app. A name is written only while its setting differs
100
+ * from the default, so an entry saved before these settings plans as it did; the app reads an absent name as
101
+ * the default (`FILES_SHARE_LINK_DAYS` 7, `FILES_SHARE_LINK_PICK` true, no `MAIL_FROM_NAME` or
102
+ * `MAIL_REPLY_TO`). A name a setting stopped writing is taken out of the app's env by the next apply.
103
+ */
104
+ export function entrySettingsEnv(entry) {
105
+ const env = {};
106
+ const files = entry.uses?.files;
107
+ if (files) {
108
+ if (files.shareLinkDays !== undefined &&
109
+ files.shareLinkDays !== FILES_SHARE_LINK_DAYS_DEFAULT) {
110
+ env.FILES_SHARE_LINK_DAYS =
111
+ files.shareLinkDays === null ? 'never' : String(files.shareLinkDays);
112
+ }
113
+ if (files.shareLinkPick === false)
114
+ env.FILES_SHARE_LINK_PICK = 'false';
115
+ }
116
+ const mail = entry.uses?.mail;
117
+ if (mail && mail !== true) {
118
+ if (mail.fromName !== undefined)
119
+ env.MAIL_FROM_NAME = mail.fromName;
120
+ if (mail.replyTo !== undefined)
121
+ env.MAIL_REPLY_TO = mail.replyTo;
122
+ }
123
+ return env;
124
+ }
77
125
  /** The env names `uses.edge` writes; `env` may not name them (#317). */
78
126
  export const EDGE_ENTRY_ENV_NAMES = ['EDGE_URL', 'EDGE_API_KEY'];
79
127
  /** The env names `uses.analytics` writes on a server entry; `env` may not name them. */
80
128
  export const ANALYTICS_ENTRY_ENV_NAMES = ['ANALYTICS_URL', 'ANALYTICS_SITE_KEY'];
129
+ /**
130
+ * What the reporting backend accepts as a site (`SITE_PATTERN` in wtfalch/reporting's core): a lower-case
131
+ * letter, then letters, digits and hyphens, at most 64 in all. An entry's site is its key.
132
+ */
133
+ export const REPORTING_SITE_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
134
+ /** The env names `uses.reporting` writes on a server entry; `env` may not name them. */
135
+ export const REPORTING_ENTRY_ENV_NAMES = ['REPORTING_URL', 'REPORTING_SITE_KEY'];
81
136
  /** The env names `uses.mail` writes; `env` may not name them. */
82
137
  export const MAIL_ENTRY_ENV_NAMES = ['LETTERMINT_API_TOKEN', 'MAIL_FROM'];
138
+ /**
139
+ * The env name every entry that sends mail ({@link sendsMail}) gets, holding its entry key, and its
140
+ * stage or workspace copy `<key>-work` ({@link mailAppKeyOf}). `@wtfalch/mailer` 0.10.0 tags each send
141
+ * with it, so Archon's mail reads can split mail per app. Not a secret. No entry's `env` may name it.
142
+ */
143
+ export const MAIL_APP_KEY_ENV = 'MAIL_APP_KEY';
144
+ /** Whether the entry sends mail: the main entry and each entry with `uses.mail`. */
145
+ export function sendsMail(entry) {
146
+ return isMainEntry(entry) || entry.uses?.mail === true;
147
+ }
148
+ /** The `MAIL_APP_KEY` of an entry's copy: its key live, `<key>-work` for its stage or workspace. */
149
+ export function mailAppKeyOf(entryKey, copy) {
150
+ return copy === 'live' ? entryKey : `${entryKey}-work`;
151
+ }
83
152
  /** The env names `uses.jobs` writes; `env` may not name them. */
84
153
  export const JOBS_ENTRY_ENV_NAMES = [
85
154
  'JOBS_SCHEDULER_URL',
@@ -749,6 +818,7 @@ function validateHostingEntry(entry, enabled, reserveKeys, github) {
749
818
  }
750
819
  if (FOUNDRY_ENV_NAME.test(name) ||
751
820
  (name === 'APP_URL' && isMainEntry(entry)) ||
821
+ name === MAIL_APP_KEY_ENV ||
752
822
  (entry.blueGreen && BLUE_GREEN_ENV_NAME.test(name))) {
753
823
  return refusal('env_name_reserved', `hosting.entries.${entry.key}.env.${name}`, `${at} env '${name}' is set by foundry and cannot be declared`);
754
824
  }
@@ -916,14 +986,36 @@ function validateEntryAuth(entry, hosting, domain) {
916
986
  const at = `hosting.entries '${entry.key}' uses.auth`;
917
987
  if (!hosting.enabled)
918
988
  return refusal('auth_needs_hosting', `hosting.entries.${entry.key}.uses.auth`, `${at} requires hosting.enabled`);
919
- if (auth.users !== 'staff' && auth.users !== 'customers') {
920
- return refusal('auth_users_invalid', `hosting.entries.${entry.key}.uses.auth.users`, `${at} users must be 'staff' or 'customers'`);
989
+ const users = auth.users;
990
+ const usersList = Array.isArray(users) ? users : [users];
991
+ if (usersList.length < 1 ||
992
+ usersList.length > ENTRY_AUDIENCES.length ||
993
+ !usersList.every((u) => ENTRY_AUDIENCES.includes(u)) ||
994
+ new Set(usersList).size !== usersList.length) {
995
+ return refusal('auth_users_invalid', `hosting.entries.${entry.key}.uses.auth.users`, `${at} users must be 'staff', 'customers' or a list of one or both of them, each once`);
996
+ }
997
+ const both = signsInBoth(auth);
998
+ // Both audiences are registered through the company's registrar (ADR 0003, auth ADR 0008).
999
+ if (both && entry.workerApp !== undefined) {
1000
+ return refusal('auth_both_worker_app', `hosting.entries.${entry.key}.uses.auth.users`, `${at} users 'customers' and 'staff' is for an app that runs as a server: a Worker app's sign-in is registered by Boule`);
1001
+ }
1002
+ if (both) {
1003
+ const unsupported = [
1004
+ [auth.mcp, 'uses.auth.mcp', 'mcp'],
1005
+ [auth.workspaces, 'uses.auth.workspaces', 'workspaces'],
1006
+ [auth.replacesBrokerEntry, 'uses.auth.replacesBrokerEntry', 'replacesBrokerEntry'],
1007
+ [entry.blueGreen, 'blueGreen', 'blueGreen'],
1008
+ ];
1009
+ const found = unsupported.find(([value]) => value !== undefined);
1010
+ if (found) {
1011
+ return refusal('auth_both_unsupported', `hosting.entries.${entry.key}.${found[1]}`, `${at} users 'customers' and 'staff' cannot have ${found[2]} yet: an app both sign in to is registered at the sign-in broker, which foundry does not do with ${found[2]}`);
1012
+ }
921
1013
  }
922
1014
  if (auth.registration !== 'open' && auth.registration !== 'closed') {
923
1015
  return refusal('auth_registration_invalid', `hosting.entries.${entry.key}.uses.auth.registration`, `${at} registration must be 'open' or 'closed'`);
924
1016
  }
925
1017
  // The staff organisation holds the org's own people and its project.
926
- if (auth.users === 'staff' && auth.registration === 'open') {
1018
+ if (!signsInCustomers(auth) && auth.registration === 'open') {
927
1019
  return refusal('registration_open_needs_customers', `hosting.entries.${entry.key}.uses.auth.registration`, `${at} registration 'open' needs users 'customers': nobody signs themselves up as staff`);
928
1020
  }
929
1021
  if (auth.replacesBrokerEntry !== undefined &&
@@ -938,7 +1030,7 @@ function validateEntryAuth(entry, hosting, domain) {
938
1030
  return refusal('auth_needs_exact_hostname', `hosting.entries.${entry.key}.hostnames`, `${at} needs an exact hostname to sign in at`);
939
1031
  }
940
1032
  if (auth.mcp !== undefined) {
941
- if (auth.users !== 'customers') {
1033
+ if (!signsInCustomers(auth)) {
942
1034
  return refusal('mcp_needs_open_customers', `hosting.entries.${entry.key}.uses.auth.mcp`, `${at} mcp needs users 'customers': an MCP client signs the customer in, and registration may be open or closed`);
943
1035
  }
944
1036
  if (typeof auth.mcp?.path !== 'string' ||
@@ -987,13 +1079,13 @@ function validateEntryAuth(entry, hosting, domain) {
987
1079
  if (signIn !== undefined && !methods.includes(signIn)) {
988
1080
  return refusal('auth_sign_ins_invalid', signInsAt, `${at} signIns must include signIn '${String(signIn)}'`);
989
1081
  }
990
- if (auth.users !== 'customers' && (methods.length !== 1 || methods[0] !== 'password')) {
1082
+ if (!signsInCustomers(auth) && (methods.length !== 1 || methods[0] !== 'password')) {
991
1083
  return refusal('sign_in_staff_password_only', signInsAt, `${at} signIns for users 'staff' must be ['password']: your team signs in with a password`);
992
1084
  }
993
1085
  if (methods.length === 1 && methods[0] === 'passkey') {
994
1086
  return refusal('passkey_needs_another_method', signInsAt, `${at} signIns 'passkey' needs 'email-code' or 'password' beside it: a first passkey and its recovery both use one of them`);
995
1087
  }
996
- if (methods.includes('email-code') && auth.users !== 'customers') {
1088
+ if (methods.includes('email-code') && !signsInCustomers(auth)) {
997
1089
  return refusal('email_code_needs_customers', signInsAt, `${at} signIns 'email-code' needs users 'customers': only customers sign in with an emailed code`);
998
1090
  }
999
1091
  // Every reader of the emailed code (the sign-in metadata, the broker entry, the workspace sign-in) reads
@@ -1007,15 +1099,15 @@ function validateEntryAuth(entry, hosting, domain) {
1007
1099
  if (!SECOND_FACTOR_MODES.includes(secondFactor)) {
1008
1100
  return refusal('auth_second_factor_invalid', `hosting.entries.${entry.key}.uses.auth.secondFactor`, `${at} secondFactor must be 'off', 'allowed' or 'required'`);
1009
1101
  }
1010
- if (secondFactor !== 'off' && auth.users !== 'customers') {
1102
+ if (secondFactor !== 'off' && !signsInCustomers(auth)) {
1011
1103
  return refusal('sign_in_staff_password_only', `hosting.entries.${entry.key}.uses.auth.secondFactor`, `${at} secondFactor for users 'staff' must be 'off': staff two-step sign-in is set for the whole company`);
1012
1104
  }
1013
1105
  }
1014
- if (signIn === 'email-code' && auth.users !== 'customers') {
1106
+ if (signIn === 'email-code' && !signsInCustomers(auth)) {
1015
1107
  return refusal('email_code_needs_customers', `hosting.entries.${entry.key}.uses.auth.signIn`, `${at} signIn 'email-code' needs users 'customers': only customers sign in with an emailed code`);
1016
1108
  }
1017
1109
  if (auth.room !== undefined) {
1018
- if (auth.users !== 'customers') {
1110
+ if (!signsInCustomers(auth)) {
1019
1111
  return refusal('auth_room_needs_customers', `hosting.entries.${entry.key}.uses.auth.room`, `${at} room needs users 'customers': only the customers' sign-in window shows it`);
1020
1112
  }
1021
1113
  const problem = roomProblem(auth.room);
@@ -1047,7 +1139,7 @@ function validateEntryAuth(entry, hosting, domain) {
1047
1139
  */
1048
1140
  function validateEntryWorkspaces(entry, workspaces, at) {
1049
1141
  const path = `hosting.entries.${entry.key}.uses.auth.workspaces`;
1050
- if (entry.uses?.auth?.users !== 'customers') {
1142
+ if (!signsInCustomers(entry.uses?.auth)) {
1051
1143
  return refusal('workspaces_needs_customers', path, `${at} workspaces needs users 'customers': workspaces belong to an app's own customers`);
1052
1144
  }
1053
1145
  if (entry.workerApp !== undefined) {
@@ -1191,7 +1283,15 @@ function validateEntryFiles(entry, hosting, domain) {
1191
1283
  if (files.shareHost && !(hosting.usesDomain && domain)) {
1192
1284
  return refusal('files_share_host_needs_domain', `hosting.entries.${entry.key}.uses.files.shareHost`, `${at} shareHost needs hosting.usesDomain and a domain: it is served at share.<domain>`);
1193
1285
  }
1194
- const written = [...FILES_ENTRY_ENV_NAMES, ...files.collections.map(filesCollectionEnvName)];
1286
+ const settings = filesSettingsProblem(files);
1287
+ if (settings) {
1288
+ return refusal('files_settings_invalid', `hosting.entries.${entry.key}.uses.files.${settings[0]}`, `${at} ${settings[1]}`);
1289
+ }
1290
+ const written = [
1291
+ ...FILES_ENTRY_ENV_NAMES,
1292
+ ...FILES_SETTINGS_ENV_NAMES.filter((name) => name in entrySettingsEnv(entry)),
1293
+ ...files.collections.map(filesCollectionEnvName),
1294
+ ];
1195
1295
  const named = Object.keys(entry.env ?? {}).find((name) => written.includes(name));
1196
1296
  if (named)
1197
1297
  return refusal('files_env_clash', `hosting.entries.${entry.key}.env.${named}`, `${at} writes ${named}; remove it from env`);
@@ -1335,7 +1435,7 @@ function validateEntryCustomerGrants(entry) {
1335
1435
  const at = `hosting.entries '${entry.key}' uses.customerGrants`;
1336
1436
  if (value !== true)
1337
1437
  return refusal('customer_grants_not_true', path, `${at} must be true`);
1338
- if (entry.uses?.auth?.users !== 'customers') {
1438
+ if (!signsInCustomers(entry.uses?.auth)) {
1339
1439
  return refusal('customer_grants_needs_customers', path, `${at} is for an app whose customers sign in: declare uses.auth with users 'customers', or remove it`);
1340
1440
  }
1341
1441
  const named = Object.keys(entry.env ?? {}).find((name) => CUSTOMER_GRANTS_ENTRY_ENV_NAMES.includes(name));
@@ -1344,20 +1444,125 @@ function validateEntryCustomerGrants(entry) {
1344
1444
  }
1345
1445
  return null;
1346
1446
  }
1347
- /** `uses.mail`, `uses.jobs` and `uses.analytics`: `true`, `hosting.enabled`, and no `env` entry that names a value they write. */
1447
+ /** #1116: the field and the words when `uses.files`' settings are not ones the module can write, else null. */
1448
+ function filesSettingsProblem(files) {
1449
+ const days = files.shareLinkDays;
1450
+ if (days !== undefined &&
1451
+ days !== null &&
1452
+ !FILES_SHARE_LINK_DAYS.includes(days)) {
1453
+ return [
1454
+ 'shareLinkDays',
1455
+ `shareLinkDays must be null or one of ${FILES_SHARE_LINK_DAYS.join(', ')}`,
1456
+ ];
1457
+ }
1458
+ if (files.shareLinkPick !== undefined && typeof files.shareLinkPick !== 'boolean') {
1459
+ return ['shareLinkPick', 'shareLinkPick must be true or false'];
1460
+ }
1461
+ if (files.spaceAlertPercent !== undefined &&
1462
+ !wholeIn(files.spaceAlertPercent, PERCENT_MIN, PERCENT_MAX)) {
1463
+ return [
1464
+ 'spaceAlertPercent',
1465
+ `spaceAlertPercent must be a whole number from ${PERCENT_MIN} to ${PERCENT_MAX}`,
1466
+ ];
1467
+ }
1468
+ return null;
1469
+ }
1470
+ const MAIL_SETTING_KEYS = ['fromName', 'replyTo', 'stopAfterBounces', 'bounceAlertPercent'];
1471
+ const JOBS_SETTING_KEYS = ['retries', 'alertOwners', 'pauseAfterFailures'];
1472
+ /** #1116: the field and the words when `uses.mail`'s settings object is not one the Email module can write, else null. */
1473
+ function mailSettingsProblem(mail) {
1474
+ const stray = Object.keys(mail).find((key) => !MAIL_SETTING_KEYS.includes(key));
1475
+ if (stray !== undefined)
1476
+ return [stray, `has no setting ${stray}`];
1477
+ const { fromName, replyTo, stopAfterBounces, bounceAlertPercent } = mail;
1478
+ if (fromName !== undefined &&
1479
+ (typeof fromName !== 'string' ||
1480
+ fromName.length > MAIL_FROM_NAME_MAX ||
1481
+ fromName.trim() !== fromName ||
1482
+ !MAIL_FROM_NAME.test(fromName))) {
1483
+ return [
1484
+ 'fromName',
1485
+ `fromName must be 1 to ${MAIL_FROM_NAME_MAX} characters, with no space at either end and none of < > " , ; \\`,
1486
+ ];
1487
+ }
1488
+ if (replyTo !== undefined &&
1489
+ (typeof replyTo !== 'string' ||
1490
+ replyTo.length > MAIL_REPLY_TO_MAX ||
1491
+ !MAIL_REPLY_TO.test(replyTo))) {
1492
+ return ['replyTo', 'replyTo must be one email address, such as help@example.com'];
1493
+ }
1494
+ if (stopAfterBounces !== undefined && !wholeIn(stopAfterBounces, 1, MAIL_BOUNCES_MAX)) {
1495
+ return [
1496
+ 'stopAfterBounces',
1497
+ `stopAfterBounces must be a whole number from 1 to ${MAIL_BOUNCES_MAX}`,
1498
+ ];
1499
+ }
1500
+ if (bounceAlertPercent !== undefined && !wholeIn(bounceAlertPercent, PERCENT_MIN, PERCENT_MAX)) {
1501
+ return [
1502
+ 'bounceAlertPercent',
1503
+ `bounceAlertPercent must be a whole number from ${PERCENT_MIN} to ${PERCENT_MAX}`,
1504
+ ];
1505
+ }
1506
+ return null;
1507
+ }
1508
+ /** #1116: the field and the words when `uses.jobs`' settings object is not one the Jobs module can write, else null. */
1509
+ function jobsSettingsProblem(jobs) {
1510
+ const stray = Object.keys(jobs).find((key) => !JOBS_SETTING_KEYS.includes(key));
1511
+ if (stray !== undefined)
1512
+ return [stray, `has no setting ${stray}`];
1513
+ const { retries, alertOwners, pauseAfterFailures } = jobs;
1514
+ if (retries !== undefined && !wholeIn(retries, 0, JOBS_RETRIES_MAX)) {
1515
+ return ['retries', `retries must be a whole number from 0 to ${JOBS_RETRIES_MAX}`];
1516
+ }
1517
+ if (alertOwners !== undefined && typeof alertOwners !== 'boolean') {
1518
+ return ['alertOwners', 'alertOwners must be true or false'];
1519
+ }
1520
+ if (pauseAfterFailures !== undefined &&
1521
+ pauseAfterFailures !== null &&
1522
+ !wholeIn(pauseAfterFailures, 1, JOBS_PAUSE_AFTER_MAX)) {
1523
+ return [
1524
+ 'pauseAfterFailures',
1525
+ `pauseAfterFailures must be null or a whole number from 1 to ${JOBS_PAUSE_AFTER_MAX}`,
1526
+ ];
1527
+ }
1528
+ return null;
1529
+ }
1530
+ /**
1531
+ * `uses.mail`, `uses.jobs`, `uses.analytics` and `uses.reporting`: `true` (mail and jobs: or their settings
1532
+ * object, #1116), `hosting.enabled`, and no `env` entry that names a value they write. `uses.reporting` also
1533
+ * needs an entry key the reporting backend takes as a site (a letter first): the site of the entry's key is the
1534
+ * entry key, and Boule reads an app's errors by it.
1535
+ */
1348
1536
  function validateEntryMailJobs(entry, hosting) {
1349
- for (const [use, written, code] of [
1350
- ['mail', MAIL_ENTRY_ENV_NAMES, 'mail'],
1351
- ['jobs', JOBS_ENTRY_ENV_NAMES, 'jobs'],
1352
- ['analytics', ANALYTICS_ENTRY_ENV_NAMES, 'analytics'],
1537
+ for (const [use, written, code, settings] of [
1538
+ // A mail setting's env name clashes only while that setting is set: absent, the app's own env value stays.
1539
+ [
1540
+ 'mail',
1541
+ [
1542
+ ...MAIL_ENTRY_ENV_NAMES,
1543
+ ...MAIL_SETTINGS_ENV_NAMES.filter((name) => name in entrySettingsEnv(entry)),
1544
+ ],
1545
+ 'mail',
1546
+ mailSettingsProblem,
1547
+ ],
1548
+ ['jobs', JOBS_ENTRY_ENV_NAMES, 'jobs', jobsSettingsProblem],
1549
+ ['analytics', ANALYTICS_ENTRY_ENV_NAMES, 'analytics', null],
1550
+ ['reporting', REPORTING_ENTRY_ENV_NAMES, 'reporting', null],
1353
1551
  ]) {
1354
1552
  const value = entry.uses?.[use];
1355
1553
  if (value === undefined)
1356
1554
  continue;
1357
1555
  const path = `hosting.entries.${entry.key}.uses.${use}`;
1358
1556
  const at = `hosting.entries '${entry.key}' uses.${use}`;
1359
- if (value !== true)
1360
- return refusal(`${code}_not_true`, path, `${at} must be true`);
1557
+ const isObject = typeof value === 'object' && value !== null && !Array.isArray(value);
1558
+ if (value !== true && !(settings && isObject)) {
1559
+ return refusal(`${code}_not_true`, path, settings ? `${at} must be true or an object of settings` : `${at} must be true`);
1560
+ }
1561
+ if (settings && isObject) {
1562
+ const bad = settings(value);
1563
+ if (bad)
1564
+ return refusal(`${code}_settings_invalid`, `${path}.${bad[0]}`, `${at} ${bad[1]}`);
1565
+ }
1361
1566
  if (!hosting.enabled) {
1362
1567
  return refusal(`${code}_needs_hosting`, path, `${at} requires hosting.enabled`);
1363
1568
  }
@@ -1365,6 +1570,9 @@ function validateEntryMailJobs(entry, hosting) {
1365
1570
  if (named) {
1366
1571
  return refusal(`${code}_env_clash`, `hosting.entries.${entry.key}.env.${named}`, `${at} writes ${named}; remove it from env`);
1367
1572
  }
1573
+ if (use === 'reporting' && !REPORTING_SITE_PATTERN.test(entry.key)) {
1574
+ return refusal('reporting_site_invalid', path, `${at} needs an entry key that starts with a letter: the key is the app's site in the reporting backend`);
1575
+ }
1368
1576
  }
1369
1577
  return null;
1370
1578
  }
@@ -1630,6 +1838,11 @@ export function refuseCustomers(org) {
1630
1838
  return refusal('customers_sign_up_needs_enabled', 'customers.signUp', 'customers.signUp needs customers.enabled');
1631
1839
  }
1632
1840
  }
1841
+ // Both audiences register in the customers organisation, which the registrar's record covers only with customers on.
1842
+ const both = org.hosting.entries.find((entry) => signsInBoth(entry.uses?.auth));
1843
+ if (both && !org.customers?.enabled) {
1844
+ return refusal('auth_both_needs_customers', `hosting.entries.${both.key}.uses.auth.users`, `hosting.entries '${both.key}' uses.auth users 'customers' and 'staff' needs customers.enabled`);
1845
+ }
1633
1846
  const open = org.hosting.entries.find((entry) => entry.uses?.auth?.registration === 'open');
1634
1847
  if (open && !(org.customers?.enabled && org.customers.signUp)) {
1635
1848
  return refusal('registration_open_needs_sign_up', `hosting.entries.${open.key}.uses.auth.registration`, `hosting.entries '${open.key}' uses.auth registration 'open' needs customers.enabled and customers.signUp`);