@wtfalch/foundry 0.62.0 → 0.64.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 (91) hide show
  1. package/dist/apps/index.d.ts +1 -0
  2. package/dist/apps/index.js +1 -0
  3. package/dist/apps/server-entry.d.ts +43 -0
  4. package/dist/apps/server-entry.js +99 -0
  5. package/dist/bootstrap/declare.js +4 -1
  6. package/dist/bootstrap/versions.d.ts +2 -0
  7. package/dist/build-json.d.ts +25 -0
  8. package/dist/build-json.js +61 -0
  9. package/dist/catalogue/copy-en.js +10 -0
  10. package/dist/catalogue/integrations.js +10 -0
  11. package/dist/index.d.ts +8 -4
  12. package/dist/index.js +3 -2
  13. package/dist/migrations/0067_requested_changes_kosmos.sql +6 -0
  14. package/dist/migrations/0068_platform_registry.sql +17 -0
  15. package/dist/modules/customer-grants.d.ts +3 -0
  16. package/dist/modules/customer-grants.js +28 -0
  17. package/dist/modules/index.js +49 -1
  18. package/dist/modules/repository.d.ts +6 -0
  19. package/dist/modules/repository.js +40 -0
  20. package/dist/modules/types.d.ts +8 -0
  21. package/dist/native-build.d.ts +7 -27
  22. package/dist/native-build.js +7 -27
  23. package/dist/org.d.ts +19 -1
  24. package/dist/org.js +45 -3
  25. package/dist/plan/archon-blue-green.d.ts +5 -0
  26. package/dist/plan/archon-blue-green.js +2 -0
  27. package/dist/plan/blue-green-apply.d.ts +5 -0
  28. package/dist/plan/blue-green-apply.js +197 -22
  29. package/dist/plan/blue-green.d.ts +61 -2
  30. package/dist/plan/blue-green.js +80 -6
  31. package/dist/plan/boule.d.ts +1 -1
  32. package/dist/plan/boule.js +2 -0
  33. package/dist/plan/broker-entries.d.ts +9 -0
  34. package/dist/plan/broker-entries.js +29 -0
  35. package/dist/plan/diff.d.ts +5 -0
  36. package/dist/plan/diff.js +21 -2
  37. package/dist/plan/edge-access.d.ts +2 -3
  38. package/dist/plan/edge-access.js +7 -0
  39. package/dist/plan/executor.d.ts +71 -1
  40. package/dist/plan/executor.js +491 -56
  41. package/dist/plan/index.d.ts +2 -0
  42. package/dist/plan/index.js +1 -0
  43. package/dist/plan/jobs-scheduler-http.d.ts +2 -0
  44. package/dist/plan/jobs-scheduler-http.js +11 -0
  45. package/dist/plan/own-limits.d.ts +6 -0
  46. package/dist/plan/own-limits.js +1 -0
  47. package/dist/plan/plan-apply.d.ts +51 -1
  48. package/dist/plan/plan-apply.js +298 -25
  49. package/dist/plan/platform-diff.d.ts +23 -1
  50. package/dist/plan/platform-diff.js +96 -0
  51. package/dist/plan/platform-org.d.ts +2 -0
  52. package/dist/plan/platform-providers.d.ts +46 -1
  53. package/dist/plan/platform-providers.js +156 -1
  54. package/dist/plan/platform-registry-keys.d.ts +58 -0
  55. package/dist/plan/platform-registry-keys.js +69 -0
  56. package/dist/plan/platform-registry.d.ts +67 -0
  57. package/dist/plan/platform-registry.js +298 -0
  58. package/dist/plan/release-host.d.ts +31 -2
  59. package/dist/plan/release-host.js +114 -6
  60. package/dist/plan/stage-copy-host.d.ts +104 -0
  61. package/dist/plan/stage-copy-host.js +259 -0
  62. package/dist/plan/stage-copy.js +44 -0
  63. package/dist/plan/stage-env.d.ts +9 -0
  64. package/dist/plan/stage-env.js +38 -0
  65. package/dist/plan/stage-guards.d.ts +57 -1
  66. package/dist/plan/stage-guards.js +106 -0
  67. package/dist/plan/stage-isolation.js +56 -0
  68. package/dist/plan/stage-services.d.ts +93 -0
  69. package/dist/plan/stage-services.js +153 -0
  70. package/dist/plan/stage-signin.d.ts +152 -0
  71. package/dist/plan/stage-signin.js +133 -0
  72. package/dist/plan/stage-sink.d.ts +2 -0
  73. package/dist/plan/stage-sink.js +6 -1
  74. package/dist/platform.d.ts +7 -1
  75. package/dist/platform.js +1 -0
  76. package/dist/release/decide.d.ts +12 -0
  77. package/dist/release/decide.js +37 -2
  78. package/dist/release/run.d.ts +71 -1
  79. package/dist/release/run.js +92 -8
  80. package/dist/release/state.d.ts +14 -1
  81. package/dist/release/state.js +43 -0
  82. package/dist/release/types.d.ts +20 -0
  83. package/dist/release/types.js +2 -0
  84. package/dist/requested-changes.d.ts +11 -2
  85. package/dist/requested-changes.js +11 -0
  86. package/dist/store/postgres.js +8 -0
  87. package/dist/store/schema.d.ts +51 -0
  88. package/dist/store/schema.js +4 -0
  89. package/package.json +3 -2
  90. package/versions.digests.json +1 -0
  91. package/versions.json +1 -0
@@ -7,15 +7,14 @@
7
7
  * workflow, and the key Boule presents when it asks the host (Archon) to
8
8
  * dispatch it.
9
9
  *
10
- * Neither function here is wired into `plan/diff.ts` or `plan/executor.ts`:
11
10
  * `dispatchNativeBuild` runs inside Archon's own `/api/native/builds`
12
- * route, not foundry's apply loop, and `mintNativeBuildKey` is left for the
13
- * host to call from wherever it writes Boule's env (`executor.ts`'s
14
- * `coolifyBoule`, the same way it already calls `mintIfAbsent` for
15
- * `BOULE_OWNER_SEED_SECRET_ENV`) once that wiring has its own review --
16
- * adding it here would touch every one of this package's Boule-convergence
17
- * fixtures for one new env var, which is a larger change than this issue
18
- * asks for.
11
+ * route, not foundry's apply loop. The key itself is minted by
12
+ * `plan/executor.ts`'s `coolifyBoule` (`ensureNativeBuildKey`), reusing the
13
+ * same `mintIfAbsent` path every other per-company Boule secret goes
14
+ * through (foundry#970) -- this file keeps only the env name and the byte
15
+ * length, not a second mint function (foundry#957 review note 2 and
16
+ * thermo note 1: the earlier `mintNativeBuildKey` was a third, buggy copy
17
+ * of that same check).
19
18
  */
20
19
  export declare const NATIVE_BUILD_REPO = "wtfalch/shell";
21
20
  export declare const NATIVE_BUILD_WORKFLOW_FILE = "build-app.yml";
@@ -49,22 +48,3 @@ export declare function dispatchNativeBuild(deps: NativeBuildDispatchDeps, run:
49
48
  /** The env name Boule presents this key under, to Archon's native-build route. */
50
49
  export declare const NATIVE_BUILD_KEY_ENV = "NATIVE_BUILD_KEY";
51
50
  export declare const NATIVE_BUILD_KEY_BYTES = 32;
52
- /** The one thing {@link mintNativeBuildKey} needs from Coolify: a live app's env, to check whether it already has a key. */
53
- export interface NativeBuildKeyEnvPort {
54
- listEnvs(appId: string): Promise<ReadonlyArray<{
55
- key: string;
56
- value: string;
57
- is_preview: boolean;
58
- }>>;
59
- }
60
- /**
61
- * Mints the per-company key Boule presents to Archon's native-build route
62
- * (estate#262), the same way every other per-company key in this codebase is
63
- * minted (estate#207): a random value, written to Boule's env once and never
64
- * rotated, kept unchanged on every later apply once it is there. Returns
65
- * `null` when Boule's env already holds one -- the caller then writes
66
- * nothing, so the live value stands. `onSecret`, when given, is told the
67
- * minted value so the caller can keep it out of its own logs and error
68
- * messages.
69
- */
70
- export declare function mintNativeBuildKey(coolify: NativeBuildKeyEnvPort, appId: string, onSecret?: (value: string) => void): Promise<string | null>;
@@ -1,4 +1,3 @@
1
- import { randomBytes } from 'node:crypto';
2
1
  /**
3
2
  * `wtfalch/shell`'s `build-app.yml`: the one GitHub Actions workflow that
4
3
  * builds and signs a company's native app shell (estate
@@ -8,15 +7,14 @@ import { randomBytes } from 'node:crypto';
8
7
  * workflow, and the key Boule presents when it asks the host (Archon) to
9
8
  * dispatch it.
10
9
  *
11
- * Neither function here is wired into `plan/diff.ts` or `plan/executor.ts`:
12
10
  * `dispatchNativeBuild` runs inside Archon's own `/api/native/builds`
13
- * route, not foundry's apply loop, and `mintNativeBuildKey` is left for the
14
- * host to call from wherever it writes Boule's env (`executor.ts`'s
15
- * `coolifyBoule`, the same way it already calls `mintIfAbsent` for
16
- * `BOULE_OWNER_SEED_SECRET_ENV`) once that wiring has its own review --
17
- * adding it here would touch every one of this package's Boule-convergence
18
- * fixtures for one new env var, which is a larger change than this issue
19
- * asks for.
11
+ * route, not foundry's apply loop. The key itself is minted by
12
+ * `plan/executor.ts`'s `coolifyBoule` (`ensureNativeBuildKey`), reusing the
13
+ * same `mintIfAbsent` path every other per-company Boule secret goes
14
+ * through (foundry#970) -- this file keeps only the env name and the byte
15
+ * length, not a second mint function (foundry#957 review note 2 and
16
+ * thermo note 1: the earlier `mintNativeBuildKey` was a third, buggy copy
17
+ * of that same check).
20
18
  */
21
19
  export const NATIVE_BUILD_REPO = 'wtfalch/shell';
22
20
  export const NATIVE_BUILD_WORKFLOW_FILE = 'build-app.yml';
@@ -72,21 +70,3 @@ export async function dispatchNativeBuild(deps, run) {
72
70
  /** The env name Boule presents this key under, to Archon's native-build route. */
73
71
  export const NATIVE_BUILD_KEY_ENV = 'NATIVE_BUILD_KEY';
74
72
  export const NATIVE_BUILD_KEY_BYTES = 32;
75
- /**
76
- * Mints the per-company key Boule presents to Archon's native-build route
77
- * (estate#262), the same way every other per-company key in this codebase is
78
- * minted (estate#207): a random value, written to Boule's env once and never
79
- * rotated, kept unchanged on every later apply once it is there. Returns
80
- * `null` when Boule's env already holds one -- the caller then writes
81
- * nothing, so the live value stands. `onSecret`, when given, is told the
82
- * minted value so the caller can keep it out of its own logs and error
83
- * messages.
84
- */
85
- export async function mintNativeBuildKey(coolify, appId, onSecret) {
86
- const envs = await coolify.listEnvs(appId);
87
- if (envs.some((e) => e.key === NATIVE_BUILD_KEY_ENV && !e.is_preview))
88
- return null;
89
- const value = randomBytes(NATIVE_BUILD_KEY_BYTES).toString('base64url');
90
- onSecret?.(value);
91
- return value;
92
- }
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' | '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' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | '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' | '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_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' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | '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' | '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.
@@ -311,6 +311,19 @@ export interface OrgRefusal {
311
311
  path: string;
312
312
  message: string;
313
313
  }
314
+ /**
315
+ * A built image a server entry runs instead of building from a repository: `repository` is
316
+ * `<host>[:port]/<owner>/<name>`, `digest` is `sha256:` and 64 lowercase hex digits. Same shapes as a server
317
+ * `build.json` (`build-json.ts`).
318
+ */
319
+ export interface EntryImage {
320
+ repository: string;
321
+ digest: `sha256:${string}`;
322
+ }
323
+ /** The registry path of an {@link EntryImage}: `<host>[:port]/<owner>/<name>`, any host. */
324
+ export declare const ENTRY_IMAGE_REPOSITORY: RegExp;
325
+ /** The digest of an {@link EntryImage}. */
326
+ export declare const ENTRY_IMAGE_DIGEST: RegExp;
314
327
  /**
315
328
  * One app the org runs. `database` and `gates` are meaningless without
316
329
  * `HostingConfig.enabled`, so `validateHosting` requires it before either is
@@ -334,6 +347,11 @@ export interface HostingEntry {
334
347
  buildPack: HostingBuildPack;
335
348
  /** The port the app listens on. `null` is Coolify's default. */
336
349
  port: number | null;
350
+ /**
351
+ * A built image the app runs, pulled as `repository@digest`, instead of a build from `repo`. Then `folder` is `null`, `branch` and `buildPack` stay at their defaults;
352
+ * `validateEntryImage` states the rest. A new digest redeploys the app.
353
+ */
354
+ image?: EntryImage;
337
355
  /**
338
356
  * The app's live address on the platform's apps domain: `<hostedName>.companyincluded.app`. A
339
357
  * label {@link hostedLabelRefusal} accepts; unique within the org (the host checks it across orgs).
package/dist/org.js CHANGED
@@ -16,6 +16,10 @@ export const APP_ENTRY_KEY = 'app';
16
16
  function refusal(code, path, message) {
17
17
  return { code, path, message };
18
18
  }
19
+ /** The registry path of an {@link EntryImage}: `<host>[:port]/<owner>/<name>`, any host. */
20
+ export const ENTRY_IMAGE_REPOSITORY = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[0-9]{1,5})?\/[a-z0-9]([a-z0-9-]*[a-z0-9])?\/[a-z0-9][a-z0-9._-]*$/;
21
+ /** The digest of an {@link EntryImage}. */
22
+ export const ENTRY_IMAGE_DIGEST = /^sha256:[0-9a-f]{64}$/;
19
23
  export const MAX_PRIVATE_PAGES = 20;
20
24
  export const MAX_PRIVATE_PAGE_ROLES = 50;
21
25
  export const MAX_PRIVATE_PAGE_PEOPLE = 200;
@@ -211,6 +215,9 @@ export function refuseHosting(hosting, names) {
211
215
  return refusal('entry_key_not_unique', `hosting.entries.${entry.key}.key`, `hosting.entries key '${entry.key}' is not unique`);
212
216
  }
213
217
  seenKeys.add(entry.key);
218
+ const invalidImage = validateEntryImage(entry);
219
+ if (invalidImage)
220
+ return invalidImage;
214
221
  const invalid = validateHostingEntry(entry, hosting.enabled, reserveKeys, hosting.github);
215
222
  if (invalid)
216
223
  return invalid;
@@ -513,8 +520,8 @@ export function companyGithubFor(hosting, repo) {
513
520
  const github = hosting.github;
514
521
  return github && repo !== null && repoUnderAccount(repo, github.account) ? github : undefined;
515
522
  }
516
- // The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
517
- const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
523
+ // The env names `pushAppEnv` and `pushDatabaseUrl` set themselves. `APP_URL` is also set, but only on the main entry.
524
+ const FOUNDRY_ENV_NAME = /^(LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
518
525
  // Set only on the colors of a blueGreen entry; any other entry may declare them.
519
526
  const BLUE_GREEN_ENV_NAME = /^(REPORTING_(COLOR|RELEASE|HEALTH_TOKEN)|NEXT_SERVER_ACTIONS_ENCRYPTION_KEY)$/;
520
527
  const SECRET_ENV_NAME = /_(KEY|SECRET|TOKEN|PASSWORD)$/;
@@ -626,6 +633,39 @@ function validateEntryPrivatePages(entry, at, hostingEnabled) {
626
633
  }
627
634
  return null;
628
635
  }
636
+ /** Why an entry's `image` is refused, or `null`. Read wherever hosting is read, so a stored entry is checked too. */
637
+ function validateEntryImage(entry) {
638
+ const value = entry.image;
639
+ if (value === undefined)
640
+ return null;
641
+ const path = `hosting.entries.${entry.key}.image`;
642
+ const at = `hosting.entries '${entry.key}'`;
643
+ const invalid = (message) => refusal('entry_image_invalid', path, `${at} ${message}`);
644
+ const isObject = typeof value === 'object' && value !== null && !Array.isArray(value);
645
+ const { repository, digest } = (isObject ? value : {});
646
+ if (!isObject ||
647
+ Object.keys(value).some((k) => k !== 'repository' && k !== 'digest') ||
648
+ typeof repository !== 'string' ||
649
+ !ENTRY_IMAGE_REPOSITORY.test(repository) ||
650
+ repository.split('/').some((part) => /^\.+$/.test(part)) ||
651
+ typeof digest !== 'string' ||
652
+ !ENTRY_IMAGE_DIGEST.test(digest)) {
653
+ return invalid('image must be { repository, digest }: a registry path like registry.example.com/owner/name, and a digest of sha256: and 64 lowercase letters a-f or digits');
654
+ }
655
+ if (entry.folder !== null || entry.branch !== 'main' || entry.buildPack !== 'nixpacks') {
656
+ return invalid("runs a built image, so it cannot also build from a repository: remove the image, or set folder to null and keep the default branch and build pack (repo may stay, as the app's source)");
657
+ }
658
+ if (entry.worker !== undefined || entry.workerApp !== undefined) {
659
+ return invalid('runs a built image on a server, so it cannot also be a Worker or a Worker app');
660
+ }
661
+ if (entry.blueGreen !== undefined ||
662
+ entry.workspace !== undefined ||
663
+ entry.live === false ||
664
+ entry.liveCommit !== undefined) {
665
+ return invalid('runs a built image, which does not support a workspace, blue/green releases or a promote yet: remove them, or build from a repository');
666
+ }
667
+ return null;
668
+ }
629
669
  function validateHostingEntry(entry, enabled, reserveKeys, github) {
630
670
  const at = `hosting.entries '${entry.key}'`;
631
671
  if (!ENTRY_KEY.test(entry.key)) {
@@ -689,7 +729,9 @@ function validateHostingEntry(entry, enabled, reserveKeys, github) {
689
729
  if (!name) {
690
730
  return refusal('env_name_empty', `hosting.entries.${entry.key}.env`, `${at} env needs a non-empty env var name`);
691
731
  }
692
- if (FOUNDRY_ENV_NAME.test(name) || (entry.blueGreen && BLUE_GREEN_ENV_NAME.test(name))) {
732
+ if (FOUNDRY_ENV_NAME.test(name) ||
733
+ (name === 'APP_URL' && isMainEntry(entry)) ||
734
+ (entry.blueGreen && BLUE_GREEN_ENV_NAME.test(name))) {
693
735
  return refusal('env_name_reserved', `hosting.entries.${entry.key}.env.${name}`, `${at} env '${name}' is set by foundry and cannot be declared`);
694
736
  }
695
737
  if (typeof value !== 'string') {
@@ -2,6 +2,7 @@ import { type BlueGreenApplyDeps } from './blue-green-apply.js';
2
2
  import { type BlueGreenSubject } from './blue-green.js';
3
3
  import type { DeployHealthRuntime } from './deploy-health.js';
4
4
  import type { PlatformTunnelPort } from './platform-providers.js';
5
+ import type { StageGuardDeps } from './stage-guards.js';
5
6
  export interface ArchonBlueGreenRun {
6
7
  subject: BlueGreenSubject;
7
8
  /** The owner company's id: the health token's held ref is `sharedSecretRef(ownerOrgId, ...)` like an entry's. */
@@ -42,6 +43,10 @@ export interface ArchonBlueGreenRun {
42
43
  sleep: (ms: number) => Promise<void>;
43
44
  logDir: string;
44
45
  isRunning: (status: string | undefined) => boolean;
46
+ /** Stage's probe port and Access gate, the same guards an entry's stage gets. Absent: the guards read "not run". */
47
+ stageGuards?: StageGuardDeps;
48
+ /** The guard names the plan counted (`desired.guards`); they go into the applied hash. */
49
+ guardInputs?: readonly string[];
45
50
  }
46
51
  /**
47
52
  * platform-archon on the blue/green path (`platform.archon.blueGreen`): the archon app keeps its name and is
@@ -70,6 +70,8 @@ export async function applyArchonBlueGreen(run) {
70
70
  generate: () => randomBytes(32).toString('hex'),
71
71
  secret: run.secret,
72
72
  }),
73
+ ...(run.stageGuards ? { stageGuards: run.stageGuards } : {}),
74
+ ...(run.guardInputs ? { guardInputs: run.guardInputs } : {}),
73
75
  actionsKey: async () => {
74
76
  const key = await mintActionsKey({
75
77
  id,
@@ -54,6 +54,11 @@ export interface BlueGreenApplyDeps {
54
54
  * guard unrecorded ("not run", never passed), so `stageGuardsReady` stays false and the copy does not start.
55
55
  */
56
56
  stageGuards?: StageGuardDeps;
57
+ /**
58
+ * The guard names the plan counted ({@link stageGuardInputs}, from `desired.guards`). They go into the
59
+ * applied structure hash, so the next plan sees the same hash until a guard becomes possible.
60
+ */
61
+ guardInputs?: readonly string[];
57
62
  /**
58
63
  * The company the stage mail sink belongs to (its name is `<slug>-stage-sink`). Absent: no sink is made and
59
64
  * stage holds no mail env at all, so the copy stays refused.
@@ -10,6 +10,8 @@ import { remoteDatabaseAddress, remoteDatabaseHost } from './remote-database.js'
10
10
  import { STAGE_SIGN_IN_NAMES, buildStageEnv } from './stage-env.js';
11
11
  import { buildStageProbes, runStageProbes, stageProbesRecord, } from './stage-guards.js';
12
12
  import { assertStageIsolated, envClass, stageIsolationHash, } from './stage-isolation.js';
13
+ import { StageServiceError, ensureStageServices } from './stage-services.js';
14
+ import { STAGE_SIGNIN_NONE, StageSignInError, ensureStagePublicClient, ensureStageSignIn, } from './stage-signin.js';
13
15
  import { STAGE_SINK_APPLIED_SCOPE_KEY, STAGE_SINK_APP_SCOPE_KEY, STAGE_SINK_HTTP_PORT, STAGE_SINK_IMAGE_TAG, STAGE_SINK_REPOSITORY, stageMailEnv, stageSinkAppName, } from './stage-sink.js';
14
16
  import { readWorkspaceCommit, wsKey } from './workspace.js';
15
17
  /** How long one Coolify call of the sink may take. */
@@ -95,7 +97,10 @@ export async function applyBlueGreenServer(deps, subject, server) {
95
97
  await deps.persist();
96
98
  }
97
99
  }
98
- deps.record(bgKey.applied(id, server.uuid), structureHash(subject, { primary: isPrimary }));
100
+ deps.record(bgKey.applied(id, server.uuid), structureHash(subject, {
101
+ primary: isPrimary,
102
+ ...(deps.guardInputs ? { guards: deps.guardInputs } : {}),
103
+ }));
99
104
  await deps.persist();
100
105
  }
101
106
  /**
@@ -481,30 +486,92 @@ export async function ensureStage(deps, subject, serverUuid) {
481
486
  const stageHas = new Map((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
482
487
  const mailChanged = [...mail, ...signInEnv].some((e) => stageHas.get(e.key) !== e.value);
483
488
  stageEnv.push(...mail);
484
- const stageSurface = {
489
+ const stageSurfaceOf = () => ({
485
490
  env: stageEnv.map((e) => ({ name: e.key, class: envClass(e.key), value: e.value })),
486
491
  resources: stageResources(subject),
492
+ });
493
+ const prodSurface = {
494
+ env: [...prodEnv, ...greenEnv]
495
+ .filter((e) => !e.is_preview && e.value !== undefined)
496
+ .map((e) => ({ name: e.key, class: envClass(e.key), value: e.value })),
497
+ resources: prodResources(subject),
498
+ internalHosts: Object.values(prodResources(subject)),
487
499
  };
488
- const violations = assertStageIsolated({
500
+ const sinkMail = Object.fromEntries(mail.map((e) => [e.key, e.value]));
501
+ const signInIsolation = deps.signIn
502
+ ? { signIn: { customers: deps.signIn.customers, live: deps.signIn.live } }
503
+ : {};
504
+ const isolationViolations = (surface) => assertStageIsolated({
489
505
  kind,
490
- prod: {
491
- env: [...prodEnv, ...greenEnv]
492
- .filter((e) => !e.is_preview && e.value !== undefined)
493
- .map((e) => ({ name: e.key, class: envClass(e.key), value: e.value })),
494
- resources: prodResources(subject),
495
- internalHosts: Object.values(prodResources(subject)),
496
- },
497
- stage: stageSurface,
498
- sinkMail: Object.fromEntries(mail.map((e) => [e.key, e.value])),
499
- ...(deps.signIn
500
- ? { signIn: { customers: deps.signIn.customers, live: deps.signIn.live } }
501
- : {}),
506
+ prod: prodSurface,
507
+ stage: surface,
508
+ sinkMail,
509
+ ...signInIsolation,
502
510
  });
511
+ // The env written this run, and the env stage already holds (put there by an earlier apply, an older
512
+ // builder or a hand): a forbidden name or a production value already on the live stage app is refused too.
513
+ // A name this run writes again is replaced, so only the other live names are checked here.
514
+ const written = new Set(stageEnv.map((e) => e.key));
515
+ const liveStage = {
516
+ env: [...stageHas]
517
+ .filter(([name, value]) => value !== undefined && !written.has(name))
518
+ .map(([name, value]) => ({ name, class: envClass(name), value })),
519
+ resources: {},
520
+ };
521
+ let stageSurface = stageSurfaceOf();
522
+ const violations = [
523
+ ...new Set([
524
+ ...isolationViolations(stageSurface),
525
+ ...assertStageIsolated({
526
+ kind,
527
+ prod: prodSurface,
528
+ stage: liveStage,
529
+ sinkMail,
530
+ ...signInIsolation,
531
+ }),
532
+ ]),
533
+ ].sort();
503
534
  if (violations.length > 0) {
504
535
  // Stage fails closed for itself: nothing is written to it, production's structure goes on.
505
536
  await stageFailed(deps, subject, `blocked:${violations.join(',')}`);
506
537
  return;
507
538
  }
539
+ // Stage's own files, ai and jobs organisations and keys, for the services production's app uses. They are made
540
+ // before the probes, which need the keys; nothing is written to the stage app until the probes were rejected.
541
+ const prodValues = new Map(prodEnv
542
+ .filter((e) => !e.is_preview && e.value !== undefined)
543
+ .map((e) => [e.key, e.value]));
544
+ let servicesRecord;
545
+ try {
546
+ const made = await ensureStageServices({
547
+ id: subject.id,
548
+ kind,
549
+ prod: prodValues,
550
+ port: deps.stageGuards?.services,
551
+ recorded: deps.recorded,
552
+ record: deps.record,
553
+ persist: deps.persist,
554
+ secret: deps.secret,
555
+ log: (line) => console.info(`${deps.label}: ${line}`),
556
+ });
557
+ stageEnv.push(...made.env);
558
+ servicesRecord = made.record;
559
+ }
560
+ catch (error) {
561
+ // A StageServiceError holds ids and refs only; any other cause can echo a credential.
562
+ if (error instanceof StageServiceError)
563
+ console.error(`${deps.label}: ${error.message}`);
564
+ await stageFailed(deps, subject, 'blocked:services', error);
565
+ return;
566
+ }
567
+ if (servicesRecord !== 'none') {
568
+ stageSurface = stageSurfaceOf();
569
+ const withServices = isolationViolations(stageSurface);
570
+ if (withServices.length > 0) {
571
+ await stageFailed(deps, subject, `blocked:${withServices.join(',')}`);
572
+ return;
573
+ }
574
+ }
508
575
  // Stage's credentials are tried against production before they are written; one that production accepts
509
576
  // blocks stage (nothing is written to it), and what was tried is recorded by name so the copy slice sees it.
510
577
  const stageValues = new Map((await coolify.listEnvs(appId))
@@ -512,7 +579,7 @@ export async function ensureStage(deps, subject, serverUuid) {
512
579
  .map((e) => [e.key, e.value]));
513
580
  for (const e of stageEnv)
514
581
  stageValues.set(e.key, e.value);
515
- const isolationHash = stageIsolationHash(stageSurface);
582
+ let isolationHash = stageIsolationHash(stageSurface);
516
583
  let probeResult;
517
584
  try {
518
585
  probeResult = await runStageProbes(buildStageProbes({
@@ -524,9 +591,12 @@ export async function ensureStage(deps, subject, serverUuid) {
524
591
  ? { liveCustomersOrgId: deps.signIn.live.customersOrgId }
525
592
  : {}),
526
593
  ...(deps.signIn ? { signInRedirectUri: deps.signIn.redirectUri } : {}),
527
- prod: new Map(prodEnv
528
- .filter((e) => !e.is_preview && e.value !== undefined)
529
- .map((e) => [e.key, e.value])),
594
+ prod: new Map([
595
+ ...prodValues,
596
+ ...(deps.stageGuards?.services?.prodJobsProjectId
597
+ ? [['JOBS_PROJECT_ID', deps.stageGuards.services.prodJobsProjectId]]
598
+ : []),
599
+ ]),
530
600
  }));
531
601
  }
532
602
  catch (error) {
@@ -534,7 +604,7 @@ export async function ensureStage(deps, subject, serverUuid) {
534
604
  await stageFailed(deps, subject, 'blocked:database', error);
535
605
  return;
536
606
  }
537
- const probes = stageProbesRecord(probeResult, isolationHash, stageValues);
607
+ let probes = stageProbesRecord(probeResult, isolationHash, stageValues);
538
608
  if (probeResult.failed.length > 0) {
539
609
  deps.record(bgKey.stageProbes(subject.id), probes.value);
540
610
  await stageFailed(deps, subject, `blocked:${probeResult.failed.join(',')}`);
@@ -544,9 +614,108 @@ export async function ensureStage(deps, subject, serverUuid) {
544
614
  await stageFailed(deps, subject, 'blocked:gate');
545
615
  return;
546
616
  }
617
+ // Stage's own sign-in: made only now, after the isolation check, the probes and the gate. Its credentials are
618
+ // added to the env and the isolation check runs once more over the whole of it.
619
+ const signIn = deps.stageGuards?.signIn;
620
+ let signInGuard;
621
+ if (signIn?.required === true && signIn.public === true) {
622
+ // A static SPA (Agora): a client and nothing secret. Its config is built from production's with the stage
623
+ // client id, then the isolation check runs once more over the whole env.
624
+ try {
625
+ const made = await ensureStagePublicClient({
626
+ id: subject.id,
627
+ port: signIn,
628
+ recorded: deps.recorded,
629
+ record: deps.record,
630
+ persist: deps.persist,
631
+ });
632
+ const rebuilt = buildStageEnv({
633
+ kind,
634
+ appUrl: stageAppUrl(subject),
635
+ logDir: deps.logDir,
636
+ database,
637
+ prod: prodEnv,
638
+ existing: [],
639
+ mint: () => '',
640
+ stageClientId: made.clientId,
641
+ });
642
+ const config = rebuilt.env.find((e) => e.key === 'AGORA_CONFIG');
643
+ if (config)
644
+ stageEnv.push(config);
645
+ signInGuard = made.guard;
646
+ }
647
+ catch (error) {
648
+ if (error instanceof StageSignInError)
649
+ console.error(`${deps.label}: ${error.message}`);
650
+ deps.record(bgKey.stageSignIn(subject.id), '');
651
+ await stageFailed(deps, subject, 'blocked:signin', error);
652
+ return;
653
+ }
654
+ for (const e of stageEnv)
655
+ stageValues.set(e.key, e.value);
656
+ stageSurface = stageSurfaceOf();
657
+ const again = isolationViolations(stageSurface);
658
+ if (again.length > 0) {
659
+ deps.record(bgKey.stageSignIn(subject.id), '');
660
+ await stageFailed(deps, subject, `blocked:${again.join(',')}`);
661
+ return;
662
+ }
663
+ isolationHash = stageIsolationHash(stageSurface);
664
+ probes = stageProbesRecord(probeResult, isolationHash, stageValues);
665
+ }
666
+ else if (signIn?.required === true) {
667
+ try {
668
+ const made = await ensureStageSignIn({
669
+ id: subject.id,
670
+ port: signIn,
671
+ recorded: deps.recorded,
672
+ record: deps.record,
673
+ persist: deps.persist,
674
+ secret: deps.secret,
675
+ generate: () => randomBytes(32).toString('base64url'),
676
+ sleep: deps.sleep,
677
+ });
678
+ stageEnv.push(...made.env);
679
+ signInGuard = made.guard;
680
+ }
681
+ catch (error) {
682
+ // A StageSignInError holds ids and refs only; any other cause can echo a credential.
683
+ if (error instanceof StageSignInError)
684
+ console.error(`${deps.label}: ${error.message}`);
685
+ deps.record(bgKey.stageSignIn(subject.id), '');
686
+ await stageFailed(deps, subject, 'blocked:signin', error);
687
+ return;
688
+ }
689
+ if (!stageEnv.some((e) => e.key === 'AUTH_COOKIE_SECRET')) {
690
+ // A cookie secret of stage's own, kept across applies; never a copy of production's.
691
+ const have = stageHas.get('AUTH_COOKIE_SECRET') || randomBytes(32).toString('base64url');
692
+ deps.secret(have);
693
+ stageEnv.push({ key: 'AUTH_COOKIE_SECRET', value: have });
694
+ }
695
+ for (const e of stageEnv)
696
+ stageValues.set(e.key, e.value);
697
+ stageSurface = stageSurfaceOf();
698
+ const again = isolationViolations(stageSurface);
699
+ if (again.length > 0) {
700
+ deps.record(bgKey.stageSignIn(subject.id), '');
701
+ await stageFailed(deps, subject, `blocked:${again.join(',')}`);
702
+ return;
703
+ }
704
+ isolationHash = stageIsolationHash(stageSurface);
705
+ probes = stageProbesRecord(probeResult, isolationHash, stageValues);
706
+ }
707
+ else if (signIn?.required === false) {
708
+ signInGuard = STAGE_SIGNIN_NONE;
709
+ }
710
+ else {
711
+ console.info(`${deps.label}: stage sign-in was not set up: the host gave no sign-in for this subject, so ${bgKey.stageSignIn(subject.id)} stays unrecorded`);
712
+ }
547
713
  await upsertEnv(deps, appId, stageEnv);
714
+ if (signInGuard)
715
+ deps.record(bgKey.stageSignIn(subject.id), signInGuard);
548
716
  if (mail.length > 0)
549
717
  deps.record(bgKey.stageSink(subject.id), 'applied');
718
+ deps.record(bgKey.stageServices(subject.id), servicesRecord);
550
719
  deps.record(bgKey.stageIsolation(subject.id), isolationHash);
551
720
  deps.record(bgKey.stageProbes(subject.id), probes.value);
552
721
  await deps.persist();
@@ -589,8 +758,11 @@ async function ensureStageGate(deps, subject) {
589
758
  if (!gate || deps.recorded()[bgKey.stageGate(subject.id)])
590
759
  return true;
591
760
  let id;
761
+ let membersHash;
592
762
  try {
593
- id = (await gate.create()).id;
763
+ const created = await gate.create();
764
+ id = created.id;
765
+ membersHash = created.membersHash;
594
766
  }
595
767
  catch (error) {
596
768
  // An Access error can echo the request, which names the people allowed in: only its name is logged.
@@ -598,6 +770,8 @@ async function ensureStageGate(deps, subject) {
598
770
  return false;
599
771
  }
600
772
  deps.record(bgKey.stageGate(subject.id), id);
773
+ if (membersHash)
774
+ deps.record(bgKey.stageGateMembers(subject.id), membersHash);
601
775
  await deps.persist();
602
776
  return true;
603
777
  }
@@ -607,7 +781,8 @@ async function stageAppId(deps, subject, name, serverUuid) {
607
781
  name,
608
782
  server: serverUuid,
609
783
  branch: subject.branch,
610
- autoDeploy: true,
784
+ // Stage deploys only when a person promotes a commit to it (`deploy_stage`); a merge never reaches it.
785
+ autoDeploy: false,
611
786
  });
612
787
  }
613
788
  function isNotFound(error) {