@wtfalch/foundry 0.66.0 → 0.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-json.d.ts +16 -1
- package/dist/build-json.js +21 -5
- package/dist/catalogue/copy-en.js +5 -0
- package/dist/catalogue/integrations.js +5 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/modules/analytics.d.ts +8 -0
- package/dist/modules/analytics.js +28 -0
- package/dist/modules/assistants.js +2 -2
- package/dist/modules/index.js +2 -0
- package/dist/org.d.ts +9 -1
- package/dist/org.js +6 -3
- package/dist/plan/analytics-boule.d.ts +17 -0
- package/dist/plan/analytics-boule.js +43 -0
- package/dist/plan/analytics.d.ts +58 -0
- package/dist/plan/analytics.js +89 -0
- package/dist/plan/blue-green.d.ts +6 -0
- package/dist/plan/blue-green.js +7 -0
- package/dist/plan/company-backups.js +2 -0
- package/dist/plan/diff.d.ts +2 -0
- package/dist/plan/diff.js +87 -0
- package/dist/plan/entry-analytics-key.d.ts +35 -0
- package/dist/plan/entry-analytics-key.js +134 -0
- package/dist/plan/executor.d.ts +36 -0
- package/dist/plan/executor.js +290 -1
- package/dist/plan/hostnames.js +4 -0
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.js +3 -0
- package/dist/plan/org-names.d.ts +3 -3
- package/dist/plan/org-names.js +3 -0
- package/dist/plan/own-limits.d.ts +6 -0
- package/dist/plan/own-limits.js +1 -0
- package/dist/plan/plan-apply.d.ts +3 -0
- package/dist/plan/plan-apply.js +15 -1
- package/dist/plan/platform-diff.d.ts +17 -1
- package/dist/plan/platform-diff.js +35 -2
- package/dist/plan/platform-providers.js +42 -2
- package/dist/plan/providers-readers.js +16 -1
- package/dist/plan/stage-services.d.ts +3 -1
- package/dist/plan/stage-services.js +2 -1
- package/dist/plan/uptime-worker.js +53 -3
- package/dist/slug.d.ts +1 -1
- package/dist/slug.js +5 -2
- package/dist/worker-app-config.js +2 -2
- package/package.json +1 -1
package/dist/build-json.d.ts
CHANGED
|
@@ -5,6 +5,17 @@ import { type WorkerAppConfig } from './worker-app-config.js';
|
|
|
5
5
|
* app. Same shapes as `WorkerAppConfig`; `repo` and `commit` say where the build came from.
|
|
6
6
|
* Whether `artifactUrl` is an asset of that release is checked by the reader that fetched it.
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* A container's image in a build.json: a registry image (`repository` and `digest`), or an asset of the
|
|
10
|
+
* release (`asset` is the file name, `sha256` its hex digest) that the reader resolves to a registry image.
|
|
11
|
+
*/
|
|
12
|
+
export type BuildJsonContainerImage = NonNullable<WorkerAppConfig['containers']>[number]['image'] | {
|
|
13
|
+
asset: string;
|
|
14
|
+
sha256: string;
|
|
15
|
+
};
|
|
16
|
+
export type BuildJsonContainer = Omit<NonNullable<WorkerAppConfig['containers']>[number], 'image'> & {
|
|
17
|
+
image: BuildJsonContainerImage;
|
|
18
|
+
};
|
|
8
19
|
export interface BuildJson {
|
|
9
20
|
repo: string;
|
|
10
21
|
commit: string;
|
|
@@ -13,7 +24,11 @@ export interface BuildJson {
|
|
|
13
24
|
compatibilityDate?: string;
|
|
14
25
|
compatibilityFlags?: 'nodejs_compat'[];
|
|
15
26
|
durableObjects: WorkerAppConfig['durableObjects'];
|
|
16
|
-
containers?:
|
|
27
|
+
containers?: BuildJsonContainer[];
|
|
28
|
+
/** Cloudflare cron triggers: at most 3 standard five-field cron expressions, each once. */
|
|
29
|
+
triggers?: {
|
|
30
|
+
crons: string[];
|
|
31
|
+
};
|
|
17
32
|
migrationsSha256?: string;
|
|
18
33
|
}
|
|
19
34
|
export type BuildJsonErrorCode = 'unknown_field' | 'missing_field' | 'wrong_type' | 'not_https' | 'bad_sha256';
|
package/dist/build-json.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { workerAppCronProblem } from './entry-rules.js';
|
|
2
3
|
import { ENTRY_IMAGE_REPOSITORY } from './org.js';
|
|
3
|
-
import { WORKER_APP_COMPATIBILITY_FLAGS, WORKER_APP_INSTANCE_TYPES, WORKER_APP_MAX_INSTANCES, } from './worker-app-config.js';
|
|
4
|
+
import { WORKER_APP_COMPATIBILITY_FLAGS, WORKER_APP_CRONS_MAX, WORKER_APP_INSTANCE_TYPES, WORKER_APP_MAX_INSTANCES, } from './worker-app-config.js';
|
|
4
5
|
/** Thrown by {@link parseBuildJson}. `path` is dotted into the build.json; the message never carries a value. */
|
|
5
6
|
export class BuildJsonError extends Error {
|
|
6
7
|
code;
|
|
@@ -39,15 +40,29 @@ const durableObjects = z.strictObject({
|
|
|
39
40
|
});
|
|
40
41
|
const container = z.strictObject({
|
|
41
42
|
className: z.string(),
|
|
42
|
-
image: z.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
image: z.union([
|
|
44
|
+
z.strictObject({
|
|
45
|
+
repository: z.string(),
|
|
46
|
+
digest: z.string().regex(DIGEST, { message: 'wrong_type' }),
|
|
47
|
+
}),
|
|
48
|
+
z.strictObject({ asset: text, sha256 }),
|
|
49
|
+
]),
|
|
46
50
|
instanceType: z.string().refine((v) => WORKER_APP_INSTANCE_TYPES.has(v), {
|
|
47
51
|
message: 'wrong_type',
|
|
48
52
|
}),
|
|
49
53
|
maxInstances: z.number().int().min(1).max(WORKER_APP_MAX_INSTANCES),
|
|
50
54
|
});
|
|
55
|
+
const cron = z.string().superRefine((value, ctx) => {
|
|
56
|
+
if (workerAppCronProblem(value) !== null)
|
|
57
|
+
ctx.addIssue({ code: 'custom', message: 'wrong_type' });
|
|
58
|
+
});
|
|
59
|
+
const triggers = z
|
|
60
|
+
.strictObject({ crons: z.array(cron).max(WORKER_APP_CRONS_MAX) })
|
|
61
|
+
.superRefine((value, ctx) => {
|
|
62
|
+
if (new Set(value.crons).size !== value.crons.length) {
|
|
63
|
+
ctx.addIssue({ code: 'custom', path: ['crons'], message: 'wrong_type' });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
51
66
|
export const buildJsonSchema = z.strictObject({
|
|
52
67
|
repo: text,
|
|
53
68
|
commit: text,
|
|
@@ -59,6 +74,7 @@ export const buildJsonSchema = z.strictObject({
|
|
|
59
74
|
.optional(),
|
|
60
75
|
durableObjects,
|
|
61
76
|
containers: z.array(container).optional(),
|
|
77
|
+
triggers: triggers.optional(),
|
|
62
78
|
migrationsSha256: sha256.optional(),
|
|
63
79
|
});
|
|
64
80
|
function valueAt(input, path) {
|
|
@@ -77,6 +77,11 @@ const INTEGRATION_COPY = {
|
|
|
77
77
|
meters: { tokens: 'AI use' },
|
|
78
78
|
outputs: { address: 'The address the app calls for AI tasks.' },
|
|
79
79
|
},
|
|
80
|
+
analytics: {
|
|
81
|
+
name: 'Count visitors',
|
|
82
|
+
summary: 'We count how many people visit the app and which pages they open, for each release.',
|
|
83
|
+
ours: 'Run by us',
|
|
84
|
+
},
|
|
80
85
|
organisations: {
|
|
81
86
|
name: 'Sign-in for organisations',
|
|
82
87
|
summary: 'Companies sign their own people in to the app.',
|
|
@@ -76,6 +76,11 @@ const EXTRA = {
|
|
|
76
76
|
price: { class: 'usage', meters: [meter('audit', 'entries', 'entries')] },
|
|
77
77
|
advanced: [],
|
|
78
78
|
},
|
|
79
|
+
analytics: {
|
|
80
|
+
providers: [ours('analytics')],
|
|
81
|
+
price: { class: 'free', meters: [] },
|
|
82
|
+
advanced: [],
|
|
83
|
+
},
|
|
79
84
|
organisations: {
|
|
80
85
|
providers: [ours('organisations')],
|
|
81
86
|
price: { class: 'free', meters: [] },
|
package/dist/index.d.ts
CHANGED
|
@@ -63,7 +63,7 @@ export type { FilesHostInput, JobsHostDeps, JobsHostInput, OwnAuthInstance, Plan
|
|
|
63
63
|
export { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY, OWN_JOBS_BLOCKED_KEY, OWN_MAIL_BLOCKED_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, provisionFilesHost, provisionJobsHost, provisionMailServer, } from './plan/index.js';
|
|
64
64
|
export type { PlanOwnSfuOptions, SfuAppDeps, SfuAppInput } from './plan/index.js';
|
|
65
65
|
export { OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, planOwnSfu, provisionSfuApp, sfuConfig, sfuCompose, } from './plan/index.js';
|
|
66
|
-
export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, KeysRegistrarPort, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, LoginHostBinding, LoginHostBindingsPort, LoginHostDiscoveryPort, } from './plan/index.js';
|
|
66
|
+
export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, KeysRegistrarPort, AnalyticsPort, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, LoginHostBinding, LoginHostBindingsPort, LoginHostDiscoveryPort, } from './plan/index.js';
|
|
67
67
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
|
68
68
|
export type { StageProbePort, ReleasePreviewMembers } from './plan/stage-guards.js';
|
|
69
69
|
export type { StageCopyHostConfig } from './plan/stage-copy-host.js';
|
|
@@ -75,6 +75,7 @@ export type { OwnHostnames } from './plan/index.js';
|
|
|
75
75
|
export type { EnsureSignInOrgOptions } from './plan/index.js';
|
|
76
76
|
export { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ensureSignInOrg, registerSignInDomain, signInOrgDomain, } from './plan/index.js';
|
|
77
77
|
export { HttpJobsSchedulerClient } from './plan/index.js';
|
|
78
|
+
export { httpAnalytics } from './plan/index.js';
|
|
78
79
|
export { httpKeysRegistrar, keysRegistrarFromConfig, KEYS_PLATFORM_TOKEN_ENV, KEYS_SERVICE_URL_ENV, } from './plan/index.js';
|
|
79
80
|
export type { HealthTarget, HealthTargetDepth, HealthTargetExpect } from './plan/index.js';
|
|
80
81
|
export { healthTargets, platformHealthTargets } from './plan/index.js';
|
package/dist/index.js
CHANGED
|
@@ -41,6 +41,7 @@ export { NO_DOMAIN_ZONE, entryMachinePaths, ownHostnames, privatePageGateKey, wo
|
|
|
41
41
|
export { hostedLabelRefusal } from './hosted-label.js';
|
|
42
42
|
export { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ensureSignInOrg, registerSignInDomain, signInOrgDomain, } from './plan/index.js';
|
|
43
43
|
export { HttpJobsSchedulerClient } from './plan/index.js';
|
|
44
|
+
export { httpAnalytics } from './plan/index.js';
|
|
44
45
|
export { httpKeysRegistrar, keysRegistrarFromConfig, KEYS_PLATFORM_TOKEN_ENV, KEYS_SERVICE_URL_ENV, } from './plan/index.js';
|
|
45
46
|
export { healthTargets, platformHealthTargets } from './plan/index.js';
|
|
46
47
|
export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeLoginHostResolver, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AppModule } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* `uses.analytics` on a Worker app: the app counts its visitors and page views. Boule installs a site key of the
|
|
4
|
+
* company's own analytics backend on each copy of the app (`ANALYTICS_URL`, `ANALYTICS_SITE_KEY`, and the
|
|
5
|
+
* `CF_VERSION_METADATA` binding the app reads its release from); foundry only sets up the backend. One on/off
|
|
6
|
+
* module, no fields. A server app declares `uses.analytics` itself and gets its key from foundry.
|
|
7
|
+
*/
|
|
8
|
+
export declare const analytics: AppModule;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { setUse } from './entry-parts.js';
|
|
2
|
+
/**
|
|
3
|
+
* `uses.analytics` on a Worker app: the app counts its visitors and page views. Boule installs a site key of the
|
|
4
|
+
* company's own analytics backend on each copy of the app (`ANALYTICS_URL`, `ANALYTICS_SITE_KEY`, and the
|
|
5
|
+
* `CF_VERSION_METADATA` binding the app reads its release from); foundry only sets up the backend. One on/off
|
|
6
|
+
* module, no fields. A server app declares `uses.analytics` itself and gets its key from foundry.
|
|
7
|
+
*/
|
|
8
|
+
export const analytics = {
|
|
9
|
+
view: {
|
|
10
|
+
id: 'analytics',
|
|
11
|
+
name: 'Count visitors',
|
|
12
|
+
line: 'We count how many people visit the app and which pages they open, for each release.',
|
|
13
|
+
category: 'Data',
|
|
14
|
+
icon: 'chart',
|
|
15
|
+
runtimes: ['worker'],
|
|
16
|
+
requires: [],
|
|
17
|
+
availability: 'ready',
|
|
18
|
+
fields: [],
|
|
19
|
+
adds: [
|
|
20
|
+
{
|
|
21
|
+
name: 'Visitor numbers',
|
|
22
|
+
hint: 'Visitors and page views for every release, on the release page. Kept in your own company only.',
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
toEntry: (entry, values) => setUse(entry, 'analytics', values === null ? undefined : true),
|
|
27
|
+
fromEntry: (entry) => (entry.uses?.analytics === true ? {} : null),
|
|
28
|
+
};
|
|
@@ -39,8 +39,8 @@ export const assistants = {
|
|
|
39
39
|
},
|
|
40
40
|
check(values, modules) {
|
|
41
41
|
const signIn = modules['sign-in'];
|
|
42
|
-
if (signIn?.users !== 'customers'
|
|
43
|
-
return problem(null, 'Help for AI assistants needs Sign-in for your customers
|
|
42
|
+
if (signIn?.users !== 'customers') {
|
|
43
|
+
return problem(null, 'Help for AI assistants needs Sign-in for your customers: each customer connects their own assistant. In Sign-in, choose "Your customers". They can sign up themselves or be invited.');
|
|
44
44
|
}
|
|
45
45
|
const onboarding = text(values, 'onboardingPath');
|
|
46
46
|
if (onboarding !== undefined && !isPagePath(onboarding)) {
|
package/dist/modules/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SECRET_LIKE_NAME, WORKER_APP_ENV_NAME, WORKER_APP_RESERVED_NAMES, } from '../entry-rules.js';
|
|
2
2
|
import { ai } from './ai.js';
|
|
3
|
+
import { analytics } from './analytics.js';
|
|
3
4
|
import { assistants } from './assistants.js';
|
|
4
5
|
import { audit } from './audit.js';
|
|
5
6
|
import { customerGrants } from './customer-grants.js';
|
|
@@ -28,6 +29,7 @@ export const MODULES = [
|
|
|
28
29
|
files,
|
|
29
30
|
email,
|
|
30
31
|
jobs,
|
|
32
|
+
analytics,
|
|
31
33
|
ai,
|
|
32
34
|
organisations,
|
|
33
35
|
audit,
|
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' | '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' | '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';
|
|
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.
|
|
@@ -732,9 +732,17 @@ export interface HostingEntryUses {
|
|
|
732
732
|
repo?: {
|
|
733
733
|
access: 'read' | 'write';
|
|
734
734
|
};
|
|
735
|
+
/**
|
|
736
|
+
* The app counts its visitors in the company's own analytics backend. A server entry gets its own site key
|
|
737
|
+
* (`ANALYTICS_URL`, `ANALYTICS_SITE_KEY`; `plan/entry-analytics-key.ts`), and its stage another. A Worker
|
|
738
|
+
* app gets its keys from Boule, which installs them per copy, so foundry only plans the backend for it.
|
|
739
|
+
*/
|
|
740
|
+
analytics?: true;
|
|
735
741
|
}
|
|
736
742
|
/** The env names `uses.edge` writes; `env` may not name them (#317). */
|
|
737
743
|
export declare const EDGE_ENTRY_ENV_NAMES: readonly ["EDGE_URL", "EDGE_API_KEY"];
|
|
744
|
+
/** The env names `uses.analytics` writes on a server entry; `env` may not name them. */
|
|
745
|
+
export declare const ANALYTICS_ENTRY_ENV_NAMES: readonly ["ANALYTICS_URL", "ANALYTICS_SITE_KEY"];
|
|
738
746
|
/** The env names `uses.mail` writes; `env` may not name them. */
|
|
739
747
|
export declare const MAIL_ENTRY_ENV_NAMES: readonly ["LETTERMINT_API_TOKEN", "MAIL_FROM"];
|
|
740
748
|
/** The env names `uses.jobs` writes; `env` may not name them. */
|
package/dist/org.js
CHANGED
|
@@ -60,6 +60,8 @@ export const FILES_ENTRY_ENV_NAMES = [
|
|
|
60
60
|
export const filesCollectionEnvName = (name) => `FILES_COLLECTION_${name}_ID`;
|
|
61
61
|
/** The env names `uses.edge` writes; `env` may not name them (#317). */
|
|
62
62
|
export const EDGE_ENTRY_ENV_NAMES = ['EDGE_URL', 'EDGE_API_KEY'];
|
|
63
|
+
/** The env names `uses.analytics` writes on a server entry; `env` may not name them. */
|
|
64
|
+
export const ANALYTICS_ENTRY_ENV_NAMES = ['ANALYTICS_URL', 'ANALYTICS_SITE_KEY'];
|
|
63
65
|
/** The env names `uses.mail` writes; `env` may not name them. */
|
|
64
66
|
export const MAIL_ENTRY_ENV_NAMES = ['LETTERMINT_API_TOKEN', 'MAIL_FROM'];
|
|
65
67
|
/** The env names `uses.jobs` writes; `env` may not name them. */
|
|
@@ -920,8 +922,8 @@ function validateEntryAuth(entry, hosting, domain) {
|
|
|
920
922
|
return refusal('auth_needs_exact_hostname', `hosting.entries.${entry.key}.hostnames`, `${at} needs an exact hostname to sign in at`);
|
|
921
923
|
}
|
|
922
924
|
if (auth.mcp !== undefined) {
|
|
923
|
-
if (auth.users !== 'customers'
|
|
924
|
-
return refusal('mcp_needs_open_customers', `hosting.entries.${entry.key}.uses.auth.mcp`, `${at} mcp needs users 'customers'
|
|
925
|
+
if (auth.users !== 'customers') {
|
|
926
|
+
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`);
|
|
925
927
|
}
|
|
926
928
|
if (typeof auth.mcp?.path !== 'string' ||
|
|
927
929
|
!MCP_PATH.test(auth.mcp.path) ||
|
|
@@ -1275,11 +1277,12 @@ function validateEntryCustomerGrants(entry) {
|
|
|
1275
1277
|
}
|
|
1276
1278
|
return null;
|
|
1277
1279
|
}
|
|
1278
|
-
/** `uses.mail` and `uses.
|
|
1280
|
+
/** `uses.mail`, `uses.jobs` and `uses.analytics`: `true`, `hosting.enabled`, and no `env` entry that names a value they write. */
|
|
1279
1281
|
function validateEntryMailJobs(entry, hosting) {
|
|
1280
1282
|
for (const [use, written, code] of [
|
|
1281
1283
|
['mail', MAIL_ENTRY_ENV_NAMES, 'mail'],
|
|
1282
1284
|
['jobs', JOBS_ENTRY_ENV_NAMES, 'jobs'],
|
|
1285
|
+
['analytics', ANALYTICS_ENTRY_ENV_NAMES, 'analytics'],
|
|
1283
1286
|
]) {
|
|
1284
1287
|
const value = entry.uses?.[use];
|
|
1285
1288
|
if (value === undefined)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* C2: while the company's Boule runs apps (`org.boule.apps`) and its analytics backend is applied, Boule's env
|
|
5
|
+
* holds `ANALYTICS_URL` and `ANALYTICS_OPERATOR_KEY`. Boule mints a site key for each app copy with the
|
|
6
|
+
* operator key and reads each release's visitors and page views with it. The key is read from the backend's own
|
|
7
|
+
* env and written to Boule's env; it is never held, recorded or planned. Without the backend Boule installs
|
|
8
|
+
* apps without analytics.
|
|
9
|
+
*
|
|
10
|
+
* One `coolify:analytics-boule` step keyed by the org id. `analytics_boule_applied` holds
|
|
11
|
+
* `<address>|<backend app id>` and is recorded last, so a new backend app (a new key) writes again. Turning
|
|
12
|
+
* apps off takes both names out of Boule's env.
|
|
13
|
+
*/
|
|
14
|
+
export declare const ANALYTICS_BOULE_APPLIED_SCOPE_KEY = "analytics_boule_applied";
|
|
15
|
+
export declare const ANALYTICS_BOULE_ENV_NAMES: readonly ["ANALYTICS_URL", "ANALYTICS_OPERATOR_KEY"];
|
|
16
|
+
export declare const analyticsBouleMarker: (org: DeclaredOrg, backendAppId: string) => string;
|
|
17
|
+
export declare function planAnalyticsBoule(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[]): DraftStep[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { appsOn } from '../org.js';
|
|
2
|
+
import { ANALYTICS_DEPLOYED_SCOPE_KEY, analyticsUrlFor } from './analytics.js';
|
|
3
|
+
/**
|
|
4
|
+
* C2: while the company's Boule runs apps (`org.boule.apps`) and its analytics backend is applied, Boule's env
|
|
5
|
+
* holds `ANALYTICS_URL` and `ANALYTICS_OPERATOR_KEY`. Boule mints a site key for each app copy with the
|
|
6
|
+
* operator key and reads each release's visitors and page views with it. The key is read from the backend's own
|
|
7
|
+
* env and written to Boule's env; it is never held, recorded or planned. Without the backend Boule installs
|
|
8
|
+
* apps without analytics.
|
|
9
|
+
*
|
|
10
|
+
* One `coolify:analytics-boule` step keyed by the org id. `analytics_boule_applied` holds
|
|
11
|
+
* `<address>|<backend app id>` and is recorded last, so a new backend app (a new key) writes again. Turning
|
|
12
|
+
* apps off takes both names out of Boule's env.
|
|
13
|
+
*/
|
|
14
|
+
export const ANALYTICS_BOULE_APPLIED_SCOPE_KEY = 'analytics_boule_applied';
|
|
15
|
+
export const ANALYTICS_BOULE_ENV_NAMES = ['ANALYTICS_URL', 'ANALYTICS_OPERATOR_KEY'];
|
|
16
|
+
export const analyticsBouleMarker = (org, backendAppId) => `${analyticsUrlFor(org)}|${backendAppId}`;
|
|
17
|
+
export function planAnalyticsBoule(org, recordedIds, plannedSteps) {
|
|
18
|
+
const draft = (mode, description) => ({
|
|
19
|
+
action: { system: 'coolify', kind: 'analytics-boule', key: org.id, desired: { mode } },
|
|
20
|
+
step: { description, destroys: mode === 'retire', migrates: false },
|
|
21
|
+
});
|
|
22
|
+
const planned = (kind) => plannedSteps.some((p) => p.action.system === 'coolify' && p.action.kind === kind && p.action.key === org.id);
|
|
23
|
+
const bouleKnown = Boolean(recordedIds[`coolify:boule:${org.id}`]) || planned('boule');
|
|
24
|
+
const marker = recordedIds[ANALYTICS_BOULE_APPLIED_SCOPE_KEY];
|
|
25
|
+
if (!appsOn(org)) {
|
|
26
|
+
if (!marker || !recordedIds[`coolify:boule:${org.id}`])
|
|
27
|
+
return [];
|
|
28
|
+
return [
|
|
29
|
+
draft('retire', `take the analytics address and key out of ${org.slug}'s Boule env (the company's Boule runs no apps)`),
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
if (!bouleKnown)
|
|
33
|
+
return [];
|
|
34
|
+
const backendApp = recordedIds[`coolify:analytics:${org.id}`];
|
|
35
|
+
const applied = Boolean(backendApp) && Boolean(recordedIds[ANALYTICS_DEPLOYED_SCOPE_KEY]);
|
|
36
|
+
if (!applied && !planned('analytics'))
|
|
37
|
+
return [];
|
|
38
|
+
if (applied && marker === analyticsBouleMarker(org, backendApp))
|
|
39
|
+
return [];
|
|
40
|
+
return [
|
|
41
|
+
draft('write', `write ANALYTICS_URL and ANALYTICS_OPERATOR_KEY to ${org.slug}'s Boule env, restart Boule`),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* A company's own analytics backend (https://github.com/wtfalch/analytics): one Coolify app and one database
|
|
4
|
+
* per company, built from that repo's root Dockerfile. It is planned only while something uses it: a hosting
|
|
5
|
+
* entry declares `uses.analytics`, or the company's Boule runs apps (`org.boule.apps`).
|
|
6
|
+
*
|
|
7
|
+
* What the host (`apps/host`) reads to start: `DATABASE_URL` and `DATABASE_URL_OWNER` (written by
|
|
8
|
+
* `pushDatabaseUrl`), `ANALYTICS_OPERATOR_KEY` (generated once here) and `AUTH_ORGANIZATION_ID` (the company's
|
|
9
|
+
* sign-in organisation; the tenant id of every key and row derives from it, and without it minting answers 503
|
|
10
|
+
* and the release series answers `not_configured`). It reads no other `AUTH_*` name yet, so no sign-in client is
|
|
11
|
+
* made for it. When its screens need `AUTH_ISSUER` and `AUTH_CLIENT_ID`, add the client then.
|
|
12
|
+
*/
|
|
13
|
+
/** True while some entry declares `uses.analytics`, or Boule runs apps: the backend is wanted. */
|
|
14
|
+
export declare const analyticsWanted: (org: Pick<DeclaredOrg, "boule" | "hosting">) => boolean;
|
|
15
|
+
/** `analytics.<domain>`, or `<slug>-analytics.oikos.host` for a company without a domain. */
|
|
16
|
+
export declare function analyticsHostnameFor(org: Pick<DeclaredOrg, 'domain' | 'slug'>): string;
|
|
17
|
+
/** The public address apps and Boule send to, `ANALYTICS_URL`. */
|
|
18
|
+
export declare const analyticsUrlFor: (org: Pick<DeclaredOrg, "domain" | "slug">) => string;
|
|
19
|
+
/** The backend's database uuid, recorded beside the app's own id. */
|
|
20
|
+
export declare const ANALYTICS_DATABASE_SCOPE_KEY = "coolify_analytics_database";
|
|
21
|
+
/** Recorded once the app was deployed, as `coolify_boule_deployed` is for Boule. */
|
|
22
|
+
export declare const ANALYTICS_DEPLOYED_SCOPE_KEY = "coolify_analytics_deployed";
|
|
23
|
+
/** The env name the backend reads its operator key from; also Boule's name for it (C2). */
|
|
24
|
+
export declare const ANALYTICS_OPERATOR_KEY_ENV = "ANALYTICS_OPERATOR_KEY";
|
|
25
|
+
/** The env names `desiredAnalyticsEnv` writes, so the live reader knows which to read back. */
|
|
26
|
+
export declare const ANALYTICS_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ORGANIZATION_ID"];
|
|
27
|
+
export declare function desiredAnalyticsEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): {
|
|
28
|
+
env: Record<string, string>;
|
|
29
|
+
missing: string[];
|
|
30
|
+
};
|
|
31
|
+
export declare function analyticsEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
|
|
32
|
+
/** The backend's operator API. Every call carries the company's own base URL and operator key. */
|
|
33
|
+
export interface AnalyticsPort {
|
|
34
|
+
/** Mints a site key; the secret is shown once. The same `name` again replaces the older key. */
|
|
35
|
+
mintSiteKey(input: {
|
|
36
|
+
baseUrl: string;
|
|
37
|
+
operatorKey: string;
|
|
38
|
+
name: string;
|
|
39
|
+
environment: 'production' | 'stage';
|
|
40
|
+
}): Promise<{
|
|
41
|
+
id: string;
|
|
42
|
+
secret: string;
|
|
43
|
+
}>;
|
|
44
|
+
/** Revokes a key. An unknown id is not an error. */
|
|
45
|
+
revokeSiteKey(input: {
|
|
46
|
+
baseUrl: string;
|
|
47
|
+
operatorKey: string;
|
|
48
|
+
keyId: string;
|
|
49
|
+
}): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The real {@link AnalyticsPort}: `POST <baseUrl>/v1/site-keys` with `{ name, environment }` answers 201
|
|
53
|
+
* `{ id, secret }`; `DELETE <baseUrl>/v1/site-keys/<id>` answers 204. Errors carry the status only, never a
|
|
54
|
+
* body, the key or the secret.
|
|
55
|
+
*/
|
|
56
|
+
export declare function httpAnalytics(options?: {
|
|
57
|
+
fetch?: typeof fetch;
|
|
58
|
+
}): AnalyticsPort;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { appsOn } from '../org.js';
|
|
2
|
+
import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
|
|
3
|
+
/**
|
|
4
|
+
* A company's own analytics backend (https://github.com/wtfalch/analytics): one Coolify app and one database
|
|
5
|
+
* per company, built from that repo's root Dockerfile. It is planned only while something uses it: a hosting
|
|
6
|
+
* entry declares `uses.analytics`, or the company's Boule runs apps (`org.boule.apps`).
|
|
7
|
+
*
|
|
8
|
+
* What the host (`apps/host`) reads to start: `DATABASE_URL` and `DATABASE_URL_OWNER` (written by
|
|
9
|
+
* `pushDatabaseUrl`), `ANALYTICS_OPERATOR_KEY` (generated once here) and `AUTH_ORGANIZATION_ID` (the company's
|
|
10
|
+
* sign-in organisation; the tenant id of every key and row derives from it, and without it minting answers 503
|
|
11
|
+
* and the release series answers `not_configured`). It reads no other `AUTH_*` name yet, so no sign-in client is
|
|
12
|
+
* made for it. When its screens need `AUTH_ISSUER` and `AUTH_CLIENT_ID`, add the client then.
|
|
13
|
+
*/
|
|
14
|
+
/** True while some entry declares `uses.analytics`, or Boule runs apps: the backend is wanted. */
|
|
15
|
+
export const analyticsWanted = (org) => appsOn(org) || org.hosting.entries.some((e) => e.uses?.analytics === true);
|
|
16
|
+
/** `analytics.<domain>`, or `<slug>-analytics.oikos.host` for a company without a domain. */
|
|
17
|
+
export function analyticsHostnameFor(org) {
|
|
18
|
+
return org.domain ? `analytics.${org.domain}` : `${org.slug}-analytics.oikos.host`;
|
|
19
|
+
}
|
|
20
|
+
/** The public address apps and Boule send to, `ANALYTICS_URL`. */
|
|
21
|
+
export const analyticsUrlFor = (org) => `https://${analyticsHostnameFor(org)}`;
|
|
22
|
+
/** The backend's database uuid, recorded beside the app's own id. */
|
|
23
|
+
export const ANALYTICS_DATABASE_SCOPE_KEY = 'coolify_analytics_database';
|
|
24
|
+
/** Recorded once the app was deployed, as `coolify_boule_deployed` is for Boule. */
|
|
25
|
+
export const ANALYTICS_DEPLOYED_SCOPE_KEY = 'coolify_analytics_deployed';
|
|
26
|
+
/** The env name the backend reads its operator key from; also Boule's name for it (C2). */
|
|
27
|
+
export const ANALYTICS_OPERATOR_KEY_ENV = 'ANALYTICS_OPERATOR_KEY';
|
|
28
|
+
/** The env names `desiredAnalyticsEnv` writes, so the live reader knows which to read back. */
|
|
29
|
+
export const ANALYTICS_MANAGED_ENV_KEYS = ['APP_URL', 'AUTH_ORGANIZATION_ID'];
|
|
30
|
+
export function desiredAnalyticsEnv(org, recorded) {
|
|
31
|
+
const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
32
|
+
const env = { APP_URL: analyticsUrlFor(org) };
|
|
33
|
+
if (zitadelOrgId)
|
|
34
|
+
env.AUTH_ORGANIZATION_ID = zitadelOrgId;
|
|
35
|
+
return { env, missing: zitadelOrgId ? [] : ['zitadelOrgId'] };
|
|
36
|
+
}
|
|
37
|
+
export function analyticsEnvDrift(desired, live) {
|
|
38
|
+
return Object.entries(desired)
|
|
39
|
+
.filter(([key, value]) => live[key] !== value)
|
|
40
|
+
.map(([key]) => key);
|
|
41
|
+
}
|
|
42
|
+
const CALL_TIMEOUT_MS = 20_000;
|
|
43
|
+
/**
|
|
44
|
+
* The real {@link AnalyticsPort}: `POST <baseUrl>/v1/site-keys` with `{ name, environment }` answers 201
|
|
45
|
+
* `{ id, secret }`; `DELETE <baseUrl>/v1/site-keys/<id>` answers 204. Errors carry the status only, never a
|
|
46
|
+
* body, the key or the secret.
|
|
47
|
+
*/
|
|
48
|
+
export function httpAnalytics(options = {}) {
|
|
49
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
50
|
+
const call = async (url, init, what) => {
|
|
51
|
+
try {
|
|
52
|
+
return await fetchImpl(url, {
|
|
53
|
+
...init,
|
|
54
|
+
redirect: 'error',
|
|
55
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new Error(`the analytics backend did not answer when asked to ${what}`);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
async mintSiteKey({ baseUrl, operatorKey, name, environment }) {
|
|
64
|
+
const response = await call(`${baseUrl}/v1/site-keys`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { authorization: `Bearer ${operatorKey}`, 'content-type': 'application/json' },
|
|
67
|
+
body: JSON.stringify({ name, environment }),
|
|
68
|
+
}, 'mint a site key');
|
|
69
|
+
if (response.status !== 201) {
|
|
70
|
+
throw Object.assign(new Error(`the analytics backend refused to mint a site key (status ${response.status})`), { status: response.status });
|
|
71
|
+
}
|
|
72
|
+
const body = await response.json().catch(() => undefined);
|
|
73
|
+
const fields = typeof body === 'object' && body !== null ? body : {};
|
|
74
|
+
if (typeof fields.id !== 'string' ||
|
|
75
|
+
!fields.id ||
|
|
76
|
+
typeof fields.secret !== 'string' ||
|
|
77
|
+
!fields.secret) {
|
|
78
|
+
throw new Error('the analytics backend answered 201 but sent no key id and secret');
|
|
79
|
+
}
|
|
80
|
+
return { id: fields.id, secret: fields.secret };
|
|
81
|
+
},
|
|
82
|
+
async revokeSiteKey({ baseUrl, operatorKey, keyId }) {
|
|
83
|
+
const response = await call(`${baseUrl}/v1/site-keys/${encodeURIComponent(keyId)}`, { method: 'DELETE', headers: { authorization: `Bearer ${operatorKey}` } }, 'revoke a site key');
|
|
84
|
+
if (response.status !== 204) {
|
|
85
|
+
throw Object.assign(new Error(`the analytics backend refused to revoke a site key (status ${response.status})`), { status: response.status });
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -379,6 +379,12 @@ export interface PlanBlueGreenInput {
|
|
|
379
379
|
probes: boolean;
|
|
380
380
|
access: boolean;
|
|
381
381
|
};
|
|
382
|
+
/**
|
|
383
|
+
* The platform's own company (`DeclaredPlatform.ownerOrgId`). Boule and Agora are our own products,
|
|
384
|
+
* so they plan blue/green only for this company (#1019); any other company keeps them as one plain
|
|
385
|
+
* app instead. Absent: not gated, for a caller with no platform in scope.
|
|
386
|
+
*/
|
|
387
|
+
ownerOrgId?: string | null;
|
|
382
388
|
}
|
|
383
389
|
/**
|
|
384
390
|
* Plans the blue/green parts of an org: per usable server, one structure step (the apps, the balancer, the
|
package/dist/plan/blue-green.js
CHANGED
|
@@ -589,6 +589,13 @@ export function planBlueGreen(input) {
|
|
|
589
589
|
description: `${org.slug}'s ${id} app blue/green is blocked, not planned: ${why}`,
|
|
590
590
|
key: `bg:${id}:${part}`,
|
|
591
591
|
});
|
|
592
|
+
if (input.ownerOrgId !== undefined &&
|
|
593
|
+
builtin &&
|
|
594
|
+
(id === 'boule' || id === 'agora') &&
|
|
595
|
+
org.id !== input.ownerOrgId) {
|
|
596
|
+
blocked("only the platform's own company runs Boule and Agora blue/green; it runs as one plain app", 'owner');
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
592
599
|
if (entry?.buildPack === 'dockercompose')
|
|
593
600
|
continue;
|
|
594
601
|
if (colorAppName(subject.base, 'green').length > MAX_NAME) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, orgServiceDatabase, } from '../org.js';
|
|
2
|
+
import { ANALYTICS_DATABASE_SCOPE_KEY } from './analytics.js';
|
|
2
3
|
import { BOULE_DATABASE_SCOPE_KEY } from './boule.js';
|
|
3
4
|
import { CHAT_REKEY_MAS_DB_SCOPE_KEY, CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY } from './executor.js';
|
|
4
5
|
import { MAIL_DATABASE_NAME, MAIL_DATABASE_SCOPE_KEY } from './mail-store.js';
|
|
@@ -29,6 +30,7 @@ export function companyDatabaseKeys(org) {
|
|
|
29
30
|
chat_synapse: 'coolify_chat_synapse_database',
|
|
30
31
|
chat_mas: 'coolify_chat_mas_database',
|
|
31
32
|
boule: BOULE_DATABASE_SCOPE_KEY,
|
|
33
|
+
analytics: ANALYTICS_DATABASE_SCOPE_KEY,
|
|
32
34
|
chat_rekey_synapse: CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY,
|
|
33
35
|
chat_rekey_mas: CHAT_REKEY_MAS_DB_SCOPE_KEY,
|
|
34
36
|
postgres: ORG_POSTGRES_SERVER_SCOPE_KEY,
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -83,6 +83,8 @@ export interface PlanOrgOptions {
|
|
|
83
83
|
workerAppKeysUrl?: string;
|
|
84
84
|
/** F7: the keys service's registrar address, when the executor has a `KeysRegistrarPort`. */
|
|
85
85
|
keysRegistrarUrl?: string;
|
|
86
|
+
/** The executor was built with an `analytics` port; without it each `uses.analytics` key is blocked. */
|
|
87
|
+
analyticsPort?: boolean;
|
|
86
88
|
/** C2: true when the executor was built with a `WorkerAppDatabasePort`; a `workerApp.database` is blocked without it. */
|
|
87
89
|
workerAppDatabase?: boolean;
|
|
88
90
|
/** True when that port has `deletableTables`; a `workerApp.database.noDelete` is blocked without it. */
|