@wtfalch/foundry 0.77.1 → 0.78.1

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/org.d.ts CHANGED
@@ -770,8 +770,8 @@ export interface EntryMail {
770
770
  bounceAlertPercent?: number;
771
771
  }
772
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.
773
+ * Jobs' settings are delivered to the scheduler's project together with the company's owners.
774
+ * `uses.jobs: true` is every setting at its default.
775
775
  */
776
776
  export interface EntryJobs {
777
777
  /** How many times a failed job is tried again. Absent is {@link JOBS_RETRIES_DEFAULT}. */
@@ -92,6 +92,11 @@ export interface PlanOrgOptions {
92
92
  reportingPort?: boolean;
93
93
  /** foundry#1119: the executor's jobs scheduler port mints and revokes project keys; without it Boule's key per `uses.jobs` entry is blocked. */
94
94
  jobsProjectKeys?: boolean;
95
+ /** The Files port can set and remove each live app's owner space alert. */
96
+ serviceAlertMail?: boolean;
97
+ serviceAlertMailCurrent?: Partial<Record<'files' | 'jobs', boolean | 'unreadable'>>;
98
+ filesSpaceAlerts?: boolean;
99
+ jobsProjectSettings?: boolean;
95
100
  /**
96
101
  * foundry#1122: ai offers a company Boule's five read permissions (`ai-boule-key.ts`). With it the organisation
97
102
  * is offered them, the org's own ai key is minted again with them, and Boule's key is minted below it; without
package/dist/plan/diff.js CHANGED
@@ -32,9 +32,11 @@ import { planEntryRepoKeys } from './entry-repo-key.js';
32
32
  import { planEntryReportingKeys } from './entry-reporting-key.js';
33
33
  import { BROKER_WORKSPACES_DATABASE_ENV, workspacesDatabaseRef } from './entry-workspaces.js';
34
34
  import { planFilesBouleKey } from './files-boule-key.js';
35
+ import { planFilesSpaceAlerts } from './files-space-alert.js';
35
36
  import { gateIdRecordKey, hasWildcardGate, planGates } from './gates.js';
36
37
  import { githubSourceResourceKey, planGithubSource } from './github-source.js';
37
38
  import { appHostname, tunnelDnsHostnames } from './hostnames.js';
39
+ import { planJobsProjectSettings } from './jobs-project-settings.js';
38
40
  import { planKeysRegistrar } from './keys-registrar.js';
39
41
  import { desiredKosmosEnv, kosmosEnvDrift } from './kosmos.js';
40
42
  import { APP_GONE_KIND } from './live.js';
@@ -61,6 +63,7 @@ import { planRegisteredApps } from './registered-app.js';
61
63
  import { planRemoteDatabase } from './remote-database.js';
62
64
  import { planReportingBoule } from './reporting-boule.js';
63
65
  import { REPORTING_MAIL_TOKEN_ENV, desiredReportingEnv, reportingEnvDrift, reportingWanted, } from './reporting.js';
66
+ import { planServiceAlertMail } from './service-alert-mail.js';
64
67
  import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, signInDomainBlockedText, signInOrgDomain, } from './sign-in-org.js';
65
68
  import { SIGN_IN_BASE_PATH, SIGN_IN_IMAGE_TAG_SUFFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_LOGIN_INTERNAL_NAME, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, inAppSignIn, inAppStatePairs, inAppTargets, signInLoginNames, } from './sign-in.js';
66
69
  import { withStepSystem } from './step-system.js';
@@ -750,6 +753,10 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
750
753
  // foundry#1121: Boule's files key, after Boule and after the org's own files key it is minted below.
751
754
  const filesBouleKey = planFilesBouleKey(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
752
755
  plannedSteps.push(...filesBouleKey.steps);
756
+ const filesSpaceAlerts = planFilesSpaceAlerts(org, recordedIds, platformReadiness.filesReady && !filesOrganisation.blocked, options.filesSpaceAlerts ?? false);
757
+ plannedSteps.push(...filesSpaceAlerts.steps);
758
+ const serviceAlertMail = planServiceAlertMail(org, platformReadiness, options.serviceAlertMail ?? false, options.ownMailKeyHeld ?? false, options.serviceAlertMailCurrent);
759
+ plannedSteps.push(...serviceAlertMail.steps);
753
760
  // foundry#1122: Boule's ai key, after Boule and after the org's own ai key it is minted below.
754
761
  const aiBouleKey = planAiBouleKey(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)(), aiBouleReads);
755
762
  plannedSteps.push(...aiBouleKey.steps);
@@ -805,6 +812,8 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
805
812
  // A `uses.jobs` entry registers with the scheduler after its app, whose env the step writes.
806
813
  const entryJobs = planEntryJobs(org, recordedIds, plannedSteps, platformReadiness, schedulerUsable);
807
814
  plannedSteps.push(...entryJobs.steps);
815
+ const jobsSettings = planJobsProjectSettings(org, recordedIds, plannedSteps, schedulerUsable, options.jobsProjectSettings ?? false);
816
+ plannedSteps.push(...jobsSettings.steps);
808
817
  // foundry#1119: Boule's key for each entry's project, after the project and after Boule.
809
818
  const entryJobsBouleKeys = planEntryJobsBouleKeys(org, recordedIds, plannedSteps, platformReadiness, options.jobsProjectKeys ?? false);
810
819
  plannedSteps.push(...entryJobsBouleKeys.steps);
@@ -1086,6 +1095,8 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
1086
1095
  unmanaged.push(...workspaces.unmanaged);
1087
1096
  unmanaged.push(...filesKeys.blocked);
1088
1097
  unmanaged.push(...filesBouleKey.blocked);
1098
+ unmanaged.push(...filesSpaceAlerts.blocked);
1099
+ unmanaged.push(...serviceAlertMail.blocked);
1089
1100
  unmanaged.push(...entryAiKeys.blocked);
1090
1101
  unmanaged.push(...aiBouleKey.blocked);
1091
1102
  unmanaged.push(...edgeWorkers.blocked);
@@ -1108,6 +1119,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
1108
1119
  unmanaged.push(...appsDbRoute.blocked);
1109
1120
  unmanaged.push(...appsZones.blocked);
1110
1121
  unmanaged.push(...entryJobs.blocked);
1122
+ unmanaged.push(...jobsSettings.blocked);
1111
1123
  unmanaged.push(...entryJobsBouleKeys.blocked);
1112
1124
  unmanaged.push(...authEventsBlocked(org, recordedIds));
1113
1125
  unmanaged.push(...entryAudit.blocked);
@@ -13,7 +13,9 @@ import { type DnsPort, type EdgePlatformDns } from './edge-dns.js';
13
13
  import { type EdgePlatformRateLimits, type RateLimitPort } from './edge-ratelimits.js';
14
14
  import { type EdgePlatformTunnels, type TunnelPort } from './edge-tunnels.js';
15
15
  import { type EdgePlatformZones, type ZonePort } from './edge-zones.js';
16
+ import { type FilesSpaceAlertPort } from './files-space-alert.js';
16
17
  import type { GithubDeployKeysPort } from './github-deploy-keys.js';
18
+ import { type JobsProjectSettings } from './jobs-project-settings.js';
17
19
  import { type KeysRegistrarPort } from './keys-registrar.js';
18
20
  import { type LoginHostBindingsPort } from './login-host.js';
19
21
  import { type MailCopyPort } from './mail-copy.js';
@@ -25,6 +27,7 @@ import type { RequestHeaderPort } from './platform-providers.js';
25
27
  import { type RegistrarApiPort } from './registered-app.js';
26
28
  import { type ReleaseContext, type ReleaseHost } from './release-host.js';
27
29
  import { type ReportingPort } from './reporting.js';
30
+ import { type ServiceAlertMailWant } from './service-alert-mail.js';
28
31
  import { type StageCopyHostConfig } from './stage-copy-host.js';
29
32
  import { type ReleasePreviewMembers, type StageProbePort } from './stage-guards.js';
30
33
  import type { WorkerAppDatabasePort } from './worker-app-database.js';
@@ -1375,6 +1378,8 @@ export interface ZitadelListAppsRawApp {
1375
1378
  * an operator's `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` pins.
1376
1379
  */
1377
1380
  export interface FilesOrganisationPort {
1381
+ setSpaceAlert?: FilesSpaceAlertPort['setSpaceAlert'];
1382
+ removeSpaceAlert?: FilesSpaceAlertPort['removeSpaceAlert'];
1378
1383
  /**
1379
1384
  * An own files host's R2 storage key, sent to that host once (the files route
1380
1385
  * `PUT /v1/manage/r2-credentials`). Absent on a port that cannot: the own R2 key step is then
@@ -1706,6 +1711,8 @@ export interface BouleOwnersPort {
1706
1711
  }>;
1707
1712
  }
1708
1713
  export interface JobsSchedulerPort {
1714
+ /** Deliver settings without re-registering or minting project credentials. */
1715
+ setProjectSettings?(project: string, settings: JobsProjectSettings): Promise<void>;
1709
1716
  registerProject(input: {
1710
1717
  name: string;
1711
1718
  baseUrl: string;
@@ -2197,6 +2204,7 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2197
2204
  * timeout. `undefined` when there is nothing to read: the ref is a tag or a commit (it names one build), or the
2198
2205
  * host supplies no read. A failed or malformed read is a fixed reason, never the cause (it can echo a credential).
2199
2206
  */
2207
+ serviceAlertMailCurrent(want: ServiceAlertMailWant, ids: Readonly<Record<string, string>>): Promise<boolean>;
2200
2208
  ownAuthHead(gitRef: string): Promise<OwnAuthHead | undefined>;
2201
2209
  /** Whether the held keys can store a Worker app database's CA and certificate. */
2202
2210
  workerAppDbKeysPuttable(): boolean;
@@ -57,11 +57,13 @@ import { ensureRepoKey, removeRepoKey } from './entry-repo-key-apply.js';
57
57
  import { ENTRY_REPORTING_KEY_DELIVERY_ATTEMPTS, entryReportingAppliedScopeKey, entryReportingRetiredScopeKey, entryReportingUndeliveredScopeKey, stageReportingKeyName, } from './entry-reporting-key.js';
58
58
  import { BROKER_WORKSPACES_DATABASE_ENV, ensureWorkspacesKey, heldWorkspacesDatabase, heldWorkspacesKey, workspacesDatabaseRef, workspacesKeyHash, workspacesKeyRef, } from './entry-workspaces.js';
59
59
  import { ensureFilesBouleKey, retireFilesBouleKey } from './files-boule-key-apply.js';
60
+ import { applyFilesSpaceAlert, } from './files-space-alert.js';
60
61
  import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate } from './gates.js';
61
62
  import { GITHUB_SOURCE_INSTALLATION_SCOPE_KEY, GITHUB_SOURCE_TIMEOUT_MS, ensureGithubSource, githubSourceResourceKey, } from './github-source.js';
62
63
  import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
63
64
  import { PROXY_LABEL_TOKEN_REF } from './held-key-refs.js';
64
65
  import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames, siteHostnameFor, tunnelDnsHostnames, tunnelIngressHostnames, workspaceHost, } from './hostnames.js';
66
+ import { applyJobsProjectSettings, } from './jobs-project-settings.js';
65
67
  import { KEYS_REGISTRAR_DELIVERY_ATTEMPTS, KEYS_REGISTRAR_KEY_DAYS, KEYS_REGISTRAR_KEY_ENV, KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY, KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, KEYS_REGISTRAR_SCOPE_KEY, KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY, KEYS_REGISTRAR_URL_ENV, } from './keys-registrar.js';
66
68
  import { KOSMOS_CLIENT_ID_SCOPE_KEY, KOSMOS_DATABASE_SCOPE_KEY, KOSMOS_DEPLOYED_SCOPE_KEY, desiredKosmosEnv, kosmosEnvDrift, kosmosHostnameFor, } from './kosmos.js';
67
69
  import { LOGIN_BASE_URI_SWEEP_SCOPE_KEY, LOGIN_BASE_URI_SWEEP_VERSION, LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, OWN_CUSTOMERS_BASE_URI_SCOPE_KEY, clientOrganizationIds, clientOrganizationsState, customersClientOrganizations, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
@@ -90,6 +92,7 @@ import { STAGE_DEPLOY_ATTEMPTS, STAGE_DEPLOY_INTERVAL_MS, createReleaseHost, } f
90
92
  import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
91
93
  import { REPORTING_BOULE_APPLIED_SCOPE_KEY, REPORTING_BOULE_ENV_NAMES, reportingBouleMarker, } from './reporting-boule.js';
92
94
  import { REPORTING_DATABASE_SCOPE_KEY, REPORTING_DEPLOYED_SCOPE_KEY, REPORTING_OPERATOR_KEY_ENV, desiredReportingEnv, reportingEnvDrift, reportingHostnameFor, reportingUrlFor, } from './reporting.js';
95
+ import { applyServiceAlertMail, serviceAlertMailCurrent, } from './service-alert-mail.js';
93
96
  import { editSignInBindings } from './sign-in-bindings.js';
94
97
  import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
95
98
  import { SIGN_IN_BASE_PATH, SIGN_IN_ENV_LIVE_SCOPE_PREFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_RATE_LIMIT_DESCRIPTION, SIGN_IN_RATE_LIMIT_EXPRESSION, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, SIGN_IN_UPSTREAM_ENV, inAppBindingFor, inAppSignIn, inAppStateHosts, inAppStatePairs, inAppTargets, signInEnvLiveHosts, } from './sign-in.js';
@@ -1147,6 +1150,14 @@ export class FoundryStepExecutor {
1147
1150
  * timeout. `undefined` when there is nothing to read: the ref is a tag or a commit (it names one build), or the
1148
1151
  * host supplies no read. A failed or malformed read is a fixed reason, never the cause (it can echo a credential).
1149
1152
  */
1153
+ async serviceAlertMailCurrent(want, ids) {
1154
+ return serviceAlertMailCurrent({
1155
+ want,
1156
+ ids,
1157
+ coolify: this.ports.coolify,
1158
+ resolve: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null,
1159
+ });
1160
+ }
1150
1161
  async ownAuthHead(gitRef) {
1151
1162
  const read = this.ports.ownServices?.readBranchHead?.bind(this.ports.ownServices);
1152
1163
  if (!read || !isBranchRef(gitRef))
@@ -1441,6 +1452,53 @@ export class FoundryStepExecutor {
1441
1452
  return this.filesOrganisation(action, org, context);
1442
1453
  case 'files:boule-key':
1443
1454
  return this.filesBouleKey(action, org, context);
1455
+ case 'coolify:service-alert-mail': {
1456
+ await applyServiceAlertMail({
1457
+ want: action.desired,
1458
+ ids: context.recordedIds(),
1459
+ coolify: this.ports.coolify,
1460
+ resolve: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null,
1461
+ secret: (value) => {
1462
+ this.handledSecrets.add(value);
1463
+ },
1464
+ record: (key, value) => context.recordScopeId(key, value),
1465
+ forget: (key) => forgetScopeId(context, key),
1466
+ persist: () => context.persistScopeIds(),
1467
+ });
1468
+ return {};
1469
+ }
1470
+ case 'jobs:settings': {
1471
+ const jobs = await this.jobsPort(org);
1472
+ if (!jobs.setProjectSettings)
1473
+ throw new Error('The scheduler cannot deliver project settings.');
1474
+ await applyJobsProjectSettings({
1475
+ org,
1476
+ want: action.desired,
1477
+ ids: context.recordedIds(),
1478
+ port: { setProjectSettings: jobs.setProjectSettings.bind(jobs) },
1479
+ record: (key, value) => context.recordScopeId(key, value),
1480
+ persist: () => context.persistScopeIds(),
1481
+ });
1482
+ return {};
1483
+ }
1484
+ case 'files:space-alert': {
1485
+ const files = await this.filesPort();
1486
+ if (!files.setSpaceAlert || !files.removeSpaceAlert)
1487
+ throw new Error('The Files service cannot set and remove space alerts.');
1488
+ await applyFilesSpaceAlert({
1489
+ org,
1490
+ want: action.desired,
1491
+ ids: context.recordedIds(),
1492
+ port: {
1493
+ setSpaceAlert: files.setSpaceAlert.bind(files),
1494
+ removeSpaceAlert: files.removeSpaceAlert.bind(files),
1495
+ },
1496
+ record: (key, value) => context.recordScopeId(key, value),
1497
+ forget: (key) => forgetScopeId(context, key),
1498
+ persist: () => context.persistScopeIds(),
1499
+ });
1500
+ return {};
1501
+ }
1444
1502
  case 'files:key':
1445
1503
  // The org's own key is keyed `<orgId>`, an entry's `<orgId>:<entry>` (F6b).
1446
1504
  return action.key.includes(':')
@@ -0,0 +1,40 @@
1
+ import { type DeclaredOrg } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ export interface FilesSpaceAlertInput {
4
+ percent?: number;
5
+ name: string;
6
+ collections: string[];
7
+ recipients: string[];
8
+ }
9
+ export interface FilesSpaceAlertPort {
10
+ setSpaceAlert(orgId: string, entryKey: string, input: FilesSpaceAlertInput): Promise<unknown>;
11
+ removeSpaceAlert(orgId: string, entryKey: string): Promise<unknown>;
12
+ }
13
+ export declare const filesSpaceAlertScopeKey: (key: string) => string;
14
+ /** Addresses are resolved only during apply. Plans and recorded ids hold their hash. */
15
+ export declare function filesSpaceAlertInput(org: DeclaredOrg, entryKey: string, ids: Readonly<Record<string, string>>): FilesSpaceAlertInput | null;
16
+ export type FilesSpaceAlertWant = {
17
+ entryKey: string;
18
+ hash: string;
19
+ } | {
20
+ entryKey: string;
21
+ remove: true;
22
+ };
23
+ export declare function planFilesSpaceAlerts(org: DeclaredOrg, ids: Readonly<Record<string, string>>, ready: boolean, capable: boolean): {
24
+ steps: DraftStep[];
25
+ blocked: {
26
+ key: string;
27
+ description: string;
28
+ }[];
29
+ };
30
+ /** Idempotent delivery; a failed or timed-out call records nothing and can be retried. */
31
+ export declare function applyFilesSpaceAlert(input: {
32
+ org: DeclaredOrg;
33
+ want: FilesSpaceAlertWant;
34
+ ids: Readonly<Record<string, string>>;
35
+ port: FilesSpaceAlertPort;
36
+ record(key: string, value: string): void;
37
+ forget(key: string): void;
38
+ persist(): Promise<void>;
39
+ timeoutMs?: number;
40
+ }): Promise<void>;
@@ -0,0 +1,90 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isLiveEntry } from '../org.js';
3
+ import { withTimeout } from './call-timeout.js';
4
+ import { entryCollectionNames } from './entry-files-key.js';
5
+ const PREFIX = 'files_space_alert_';
6
+ export const filesSpaceAlertScopeKey = (key) => `${PREFIX}${key}`;
7
+ /** Addresses are resolved only during apply. Plans and recorded ids hold their hash. */
8
+ export function filesSpaceAlertInput(org, entryKey, ids) {
9
+ const entry = org.hosting.enabled
10
+ ? org.hosting.entries.find((e) => e.key === entryKey && isLiveEntry(e) && e.uses?.files)
11
+ : undefined;
12
+ if (!entry?.uses?.files)
13
+ return null;
14
+ return {
15
+ name: entry.key,
16
+ ...(entry.uses.files.spaceAlertPercent === undefined
17
+ ? {}
18
+ : { percent: entry.uses.files.spaceAlertPercent }),
19
+ collections: [...new Set(Object.values(entryCollectionNames(entry, ids)))].sort(),
20
+ recipients: [...new Set(org.owners.map((owner) => owner.email.trim().toLowerCase()))].sort(),
21
+ };
22
+ }
23
+ const hashOf = (input) => createHash('sha256').update(JSON.stringify(input)).digest('hex');
24
+ export function planFilesSpaceAlerts(org, ids, ready, capable) {
25
+ const steps = [];
26
+ const blocked = [];
27
+ const keys = new Set([
28
+ ...org.hosting.entries.filter((e) => isLiveEntry(e) && e.uses?.files).map((e) => e.key),
29
+ ...Object.keys(ids)
30
+ .filter((key) => key.startsWith(PREFIX))
31
+ .map((key) => key.slice(PREFIX.length)),
32
+ ]);
33
+ for (const entryKey of [...keys].sort()) {
34
+ const input = filesSpaceAlertInput(org, entryKey, ids);
35
+ const hash = input ? hashOf(input) : null;
36
+ if (hash === ids[filesSpaceAlertScopeKey(entryKey)] ||
37
+ (!input && !ids[filesSpaceAlertScopeKey(entryKey)]))
38
+ continue;
39
+ const key = `files:space-alert:${org.id}:${entryKey}`;
40
+ const reason = !ready
41
+ ? 'the Files service is not ready'
42
+ : !capable
43
+ ? 'the host cannot set and remove space alerts'
44
+ : input && (input.collections.length < 1 || input.collections.length > 50)
45
+ ? 'the app needs between one and fifty collections'
46
+ : input && input.recipients.length > 20
47
+ ? 'the service accepts at most twenty owners'
48
+ : null;
49
+ if (reason) {
50
+ blocked.push({
51
+ key,
52
+ description: `${org.slug}'s space alert for ${entryKey} is blocked: ${reason}`,
53
+ });
54
+ continue;
55
+ }
56
+ steps.push({
57
+ action: {
58
+ system: 'files',
59
+ kind: 'space-alert',
60
+ key: `${org.id}:${entryKey}`,
61
+ desired: input ? { entryKey, hash } : { entryKey, remove: true },
62
+ },
63
+ step: {
64
+ description: `${input ? 'set' : 'remove'} the space alert for ${org.slug}'s ${entryKey}`,
65
+ destroys: !input,
66
+ migrates: false,
67
+ },
68
+ });
69
+ }
70
+ return { steps, blocked };
71
+ }
72
+ /** Idempotent delivery; a failed or timed-out call records nothing and can be retried. */
73
+ export async function applyFilesSpaceAlert(input) {
74
+ const { org, want, ids, port } = input;
75
+ const desired = filesSpaceAlertInput(org, want.entryKey, ids);
76
+ const marker = filesSpaceAlertScopeKey(want.entryKey);
77
+ if ('remove' in want) {
78
+ if (desired)
79
+ throw new Error('The app uses Files again. Make a new plan before removing its alert.');
80
+ await withTimeout('Files space alert removal', input.timeoutMs ?? 30_000, () => port.removeSpaceAlert(org.id, want.entryKey));
81
+ input.forget(marker);
82
+ }
83
+ else {
84
+ if (!desired || hashOf(desired) !== want.hash)
85
+ throw new Error('The app or its owners changed. Make a new plan before setting its alert.');
86
+ await withTimeout('Files space alert delivery', input.timeoutMs ?? 30_000, () => port.setSpaceAlert(org.id, want.entryKey, desired));
87
+ input.record(marker, want.hash);
88
+ }
89
+ await input.persist();
90
+ }
@@ -0,0 +1,33 @@
1
+ import { type DeclaredOrg } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ export interface JobsProjectSettings {
4
+ retries: number;
5
+ alertOwners: boolean;
6
+ pauseAfterFailures: number | null;
7
+ owners: string[];
8
+ }
9
+ export interface JobsProjectSettingsPort {
10
+ setProjectSettings(project: string, settings: JobsProjectSettings): Promise<void>;
11
+ }
12
+ export interface JobsProjectSettingsWant {
13
+ entryKey: string;
14
+ hash: string;
15
+ }
16
+ export declare const jobsProjectSettingsScopeKey: (key: string) => string;
17
+ export declare function jobsProjectSettings(org: DeclaredOrg, entryKey: string): JobsProjectSettings | null;
18
+ export declare function planJobsProjectSettings(org: DeclaredOrg, ids: Readonly<Record<string, string>>, planned: readonly DraftStep[], ready: boolean, capable: boolean): {
19
+ steps: DraftStep[];
20
+ blocked: {
21
+ key: string;
22
+ description: string;
23
+ }[];
24
+ };
25
+ export declare function applyJobsProjectSettings(input: {
26
+ org: DeclaredOrg;
27
+ want: JobsProjectSettingsWant;
28
+ ids: Readonly<Record<string, string>>;
29
+ port: JobsProjectSettingsPort;
30
+ record(key: string, value: string): void;
31
+ persist(): Promise<void>;
32
+ timeoutMs?: number;
33
+ }): Promise<void>;
@@ -0,0 +1,77 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { JOBS_RETRIES_DEFAULT } from '../entry-rules.js';
3
+ import { isLiveEntry } from '../org.js';
4
+ import { withTimeout } from './call-timeout.js';
5
+ import { entryJobsAppliedScopeKey, entryJobsProjectName, entryJobsUndeliveredScopeKey, } from './entry-jobs.js';
6
+ export const jobsProjectSettingsScopeKey = (key) => `jobs_project_settings.${key}`;
7
+ export function jobsProjectSettings(org, entryKey) {
8
+ const entry = org.hosting.enabled
9
+ ? org.hosting.entries.find((e) => e.key === entryKey && isLiveEntry(e) && e.uses?.jobs)
10
+ : undefined;
11
+ if (!entry?.uses?.jobs)
12
+ return null;
13
+ const settings = entry.uses.jobs === true ? {} : entry.uses.jobs;
14
+ return {
15
+ retries: settings.retries ?? JOBS_RETRIES_DEFAULT,
16
+ alertOwners: settings.alertOwners ?? true,
17
+ pauseAfterFailures: settings.pauseAfterFailures ?? null,
18
+ owners: [...new Set(org.owners.map((owner) => owner.email.trim().toLowerCase()))].sort(),
19
+ };
20
+ }
21
+ const hashOf = (project, settings) => createHash('sha256')
22
+ .update(JSON.stringify([project, settings]))
23
+ .digest('hex');
24
+ export function planJobsProjectSettings(org, ids, planned, ready, capable) {
25
+ const steps = [];
26
+ const blocked = [];
27
+ for (const entry of org.hosting.entries) {
28
+ const settings = jobsProjectSettings(org, entry.key);
29
+ if (!settings)
30
+ continue;
31
+ const hash = hashOf(entryJobsProjectName(org, entry.key), settings);
32
+ if (ids[jobsProjectSettingsScopeKey(entry.key)] === hash)
33
+ continue;
34
+ const key = `${org.id}:${entry.key}`;
35
+ const projectReady = !ids[entryJobsUndeliveredScopeKey(entry.key)] &&
36
+ (Boolean(ids[entryJobsAppliedScopeKey(entry.key)]) ||
37
+ planned.some((p) => p.action.system === 'jobs' && p.action.kind === 'project' && p.action.key === key));
38
+ const reason = !ready
39
+ ? 'the scheduler is not ready'
40
+ : !capable
41
+ ? 'the host cannot deliver project settings'
42
+ : !projectReady
43
+ ? 'the project and its credentials must be provisioned first'
44
+ : settings.owners.length > 50
45
+ ? 'the scheduler accepts at most fifty owners'
46
+ : null;
47
+ if (reason) {
48
+ blocked.push({
49
+ key: `jobs:settings:${key}`,
50
+ description: `${org.slug}'s settings for ${entry.key} are blocked: ${reason}`,
51
+ });
52
+ continue;
53
+ }
54
+ steps.push({
55
+ action: { system: 'jobs', kind: 'settings', key, desired: { entryKey: entry.key, hash } },
56
+ step: {
57
+ description: `set the job settings and owner alerts for ${org.slug}'s ${entry.key}`,
58
+ destroys: false,
59
+ migrates: false,
60
+ },
61
+ });
62
+ }
63
+ return { steps, blocked };
64
+ }
65
+ export async function applyJobsProjectSettings(input) {
66
+ const { org, want, ids } = input;
67
+ const settings = jobsProjectSettings(org, want.entryKey);
68
+ const name = entryJobsProjectName(org, want.entryKey);
69
+ if (!settings || hashOf(name, settings) !== want.hash)
70
+ throw new Error('The app or its owners changed. Make a new plan before setting its job settings.');
71
+ if (!ids[entryJobsAppliedScopeKey(want.entryKey)] ||
72
+ ids[entryJobsUndeliveredScopeKey(want.entryKey)])
73
+ throw new Error('The job project and its credentials must be provisioned before setting its settings.');
74
+ await withTimeout('Job settings delivery', input.timeoutMs ?? 30_000, () => input.port.setProjectSettings(name, settings));
75
+ input.record(jobsProjectSettingsScopeKey(want.entryKey), want.hash);
76
+ await input.persist();
77
+ }
@@ -1,4 +1,5 @@
1
1
  import type { JobsSchedulerPort } from './executor.js';
2
+ import type { JobsProjectSettings } from './jobs-project-settings.js';
2
3
  /**
3
4
  * #173: the real {@link JobsSchedulerPort}, a thin client over the jobs
4
5
  * scheduler's admin HTTP API (https://github.com/wtfalch/jobs README, "The
@@ -17,6 +18,7 @@ export declare class HttpJobsSchedulerClient implements JobsSchedulerPort {
17
18
  private readonly fetchImpl;
18
19
  private readonly baseUrl;
19
20
  constructor(baseUrl: string, adminToken: string, fetchImpl?: typeof fetch);
21
+ setProjectSettings(project: string, settings: JobsProjectSettings): Promise<void>;
20
22
  registerProject(input: {
21
23
  name: string;
22
24
  baseUrl: string;
@@ -22,6 +22,16 @@ export class HttpJobsSchedulerClient {
22
22
  this.fetchImpl = fetchImpl;
23
23
  this.baseUrl = baseUrl.replace(/\/+$/, '');
24
24
  }
25
+ async setProjectSettings(project, settings) {
26
+ const response = await this.fetchImpl(`${this.baseUrl}/admin/projects/${encodeURIComponent(project)}/settings`, {
27
+ method: 'PUT',
28
+ headers: { authorization: `Bearer ${this.adminToken}`, 'content-type': 'application/json' },
29
+ body: JSON.stringify(settings),
30
+ signal: AbortSignal.timeout(ADMIN_CALL_TIMEOUT_MS),
31
+ });
32
+ if (response.status !== 200)
33
+ throw new Error(`jobs scheduler: PUT /admin/projects/<name>/settings answered ${response.status}`);
34
+ }
25
35
  async registerProject(input) {
26
36
  const response = await this.fetchImpl(`${this.baseUrl}/admin/projects`, {
27
37
  method: 'POST',
@@ -22,6 +22,7 @@ import type { OwnAuthHead } from './own-diff.js';
22
22
  import { type EdgePortName, type HeldKeyResolver, type PlatformOwnerInput } from './platform-org.js';
23
23
  import type { CallsSfuConnectionPort } from './platform-sfu.js';
24
24
  import { type ReleaseContext, type ReleaseHost } from './release-host.js';
25
+ import { type ServiceAlertMailWant } from './service-alert-mail.js';
25
26
  /** Who did it, for `@wtfalch/audit`'s "who, when, the plan, the result" (#9, #19). */
26
27
  export interface AuditActor {
27
28
  readonly id: string;
@@ -182,6 +183,7 @@ export interface StepExecutionContext {
182
183
  * `FoundryStepExecutor`).
183
184
  */
184
185
  export interface StepExecutor {
186
+ serviceAlertMailCurrent?(want: ServiceAlertMailWant, ids: Readonly<Record<string, string>>): Promise<boolean>;
185
187
  execute(action: StepAction, context: StepExecutionContext): Promise<{
186
188
  resourceId?: string;
187
189
  } | undefined>;
@@ -626,6 +628,10 @@ export interface FoundryPlanApplyOptions {
626
628
  * `uses.jobs` entry's Boule key is reported blocked.
627
629
  */
628
630
  jobsProjectKeys?: boolean;
631
+ /** The host can deliver per-app Files space alerts. */
632
+ serviceAlertMail?: boolean;
633
+ filesSpaceAlerts?: boolean;
634
+ jobsProjectSettings?: boolean;
629
635
  /**
630
636
  * foundry#1122: true once ai offers a company Boule's five read permissions (`ai.usage:read`,
631
637
  * `ai.runs:read`, `ai.budgets:read`, `ai.budgets:update`, `ai.offerings:read`; all `offered: false` today).
@@ -801,6 +807,9 @@ export declare class FoundryPlanApply implements PlanApply {
801
807
  private readonly analyticsPort;
802
808
  private readonly reportingPort;
803
809
  private readonly jobsProjectKeys;
810
+ private readonly serviceAlertMail;
811
+ private readonly filesSpaceAlerts;
812
+ private readonly jobsProjectSettings;
804
813
  private readonly aiBouleReads;
805
814
  private readonly workerAppDatabase;
806
815
  private readonly workerAppNoDeleteCheck;
@@ -1060,6 +1069,7 @@ export declare class FoundryPlanApply implements PlanApply {
1060
1069
  /** #886: the head of an own org's declared auth branch, or nothing when the org is not own or the executor has no read. */
1061
1070
  private ownAuthHeadFor;
1062
1071
  /** An own org's Lettermint key is held. A resolver that throws counts as not held. */
1072
+ private readServiceAlertMail;
1063
1073
  private ownMailKeyHeld;
1064
1074
  /** An own org's registrar token is held (a rerun after a crash delivers it instead of blocking). */
1065
1075
  private ownRegistrarTokenHeld;
@@ -41,6 +41,7 @@ import { privatePageGateKey } from './private-pages.js';
41
41
  import { CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY, CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, } from './proxy-labels.js';
42
42
  import { finishStageCopy, runSubjectRelease, } from './release-host.js';
43
43
  import { remoteDatabaseFactsFrom, remoteDatabaseKeys } from './remote-database.js';
44
+ import { planServiceAlertMail } from './service-alert-mail.js';
44
45
  import { STAGE_COPY_LOCK_MS, claimStageCopyLock, parseStageCopyLock, stageCopyLockValue, } from './stage-copy-host.js';
45
46
  import { heldSecretId, heldSecretVersion, liveWorkerApps, workerAppTraceScopeKeys, } from './worker-app.js';
46
47
  import { liveWorkers, workerTraceScopeKeys } from './worker.js';
@@ -194,6 +195,9 @@ export class FoundryPlanApply {
194
195
  analyticsPort;
195
196
  reportingPort;
196
197
  jobsProjectKeys;
198
+ serviceAlertMail;
199
+ filesSpaceAlerts;
200
+ jobsProjectSettings;
197
201
  aiBouleReads;
198
202
  workerAppDatabase;
199
203
  workerAppNoDeleteCheck;
@@ -244,6 +248,9 @@ export class FoundryPlanApply {
244
248
  this.analyticsPort = options.analyticsPort ?? false;
245
249
  this.reportingPort = options.reportingPort ?? false;
246
250
  this.jobsProjectKeys = options.jobsProjectKeys ?? false;
251
+ this.filesSpaceAlerts = options.filesSpaceAlerts ?? false;
252
+ this.serviceAlertMail = options.serviceAlertMail ?? false;
253
+ this.jobsProjectSettings = options.jobsProjectSettings ?? false;
247
254
  this.aiBouleReads = options.aiBouleReads ?? false;
248
255
  this.workerAppDatabase = options.workerAppDatabase ?? false;
249
256
  this.workerAppNoDeleteCheck = options.workerAppNoDeleteCheck ?? false;
@@ -1720,6 +1727,10 @@ export class FoundryPlanApply {
1720
1727
  analyticsPort: this.analyticsPort,
1721
1728
  reportingPort: this.reportingPort,
1722
1729
  jobsProjectKeys: this.jobsProjectKeys,
1730
+ filesSpaceAlerts: this.filesSpaceAlerts,
1731
+ serviceAlertMail: this.serviceAlertMail,
1732
+ serviceAlertMailCurrent: await this.readServiceAlertMail(org, platformReadiness, recorded),
1733
+ jobsProjectSettings: this.jobsProjectSettings,
1723
1734
  aiBouleReads: this.aiBouleReads,
1724
1735
  workerAppDatabase: this.workerAppDatabase,
1725
1736
  workerAppNoDeleteCheck: this.workerAppNoDeleteCheck,
@@ -1995,6 +2006,21 @@ export class FoundryPlanApply {
1995
2006
  return head ? { ownAuthHead: head } : {};
1996
2007
  }
1997
2008
  /** An own org's Lettermint key is held. A resolver that throws counts as not held. */
2009
+ async readServiceAlertMail(org, readiness, ids) {
2010
+ const current = {};
2011
+ const planned = planServiceAlertMail(org, readiness, this.serviceAlertMail, await this.ownMailKeyHeld(org));
2012
+ for (const step of planned.steps) {
2013
+ const want = step.action.desired;
2014
+ try {
2015
+ current[want.service] =
2016
+ (await this.executor.serviceAlertMailCurrent?.(want, ids)) ?? 'unreadable';
2017
+ }
2018
+ catch {
2019
+ current[want.service] = 'unreadable';
2020
+ }
2021
+ }
2022
+ return current;
2023
+ }
1998
2024
  async ownMailKeyHeld(org) {
1999
2025
  const held = this.platformDeps?.heldKeys;
2000
2026
  if (!ownsServices(org) || !held)
@@ -1262,6 +1262,7 @@ export declare const PLATFORM_USAGE_TAIL_SCRIPT_NAME = "platform-usage-tail";
1262
1262
  export declare const PLATFORM_USAGE_TAIL_KEY_REF = "usage-tail:worker:key";
1263
1263
  export declare const PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY = "usage_tail_key_undelivered";
1264
1264
  export declare const PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY = "usage_tail_key_set";
1265
+ export declare const PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY = "usage_tail_applied_url";
1265
1266
  export declare const PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY = "usage_tail_applied_tag";
1266
1267
  export declare const PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY = "usage_tail_applied_sha256";
1267
1268
  export declare const PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY = "usage_tail_removed";
@@ -1461,6 +1461,7 @@ export const PLATFORM_USAGE_TAIL_SCRIPT_NAME = 'platform-usage-tail';
1461
1461
  export const PLATFORM_USAGE_TAIL_KEY_REF = 'usage-tail:worker:key';
1462
1462
  export const PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY = 'usage_tail_key_undelivered';
1463
1463
  export const PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY = 'usage_tail_key_set';
1464
+ export const PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY = 'usage_tail_applied_url';
1464
1465
  export const PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY = 'usage_tail_applied_tag';
1465
1466
  export const PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY = 'usage_tail_applied_sha256';
1466
1467
  export const PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY = 'usage_tail_removed';
@@ -3488,12 +3489,17 @@ function planUsageTail(platform, recordedIds, executorBlocked, edgeApplied) {
3488
3489
  const applied = deployed &&
3489
3490
  Boolean(recordedIds[PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY]) &&
3490
3491
  recordedIds[PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY] === usageTail.tag &&
3491
- recordedIds[PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY] === usageTail.sha256;
3492
+ recordedIds[PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY] === usageTail.sha256 &&
3493
+ recordedIds[PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY] === `https://${platform.edge.hostname}`;
3492
3494
  if (applied)
3493
3495
  return { step: null, blocked: [] };
3494
3496
  return {
3495
3497
  step: {
3496
- action: action({ gitRef: usageTail.tag, sha256: usageTail.sha256 }),
3498
+ action: action({
3499
+ gitRef: usageTail.tag,
3500
+ sha256: usageTail.sha256,
3501
+ url: `https://${platform.edge.hostname}`,
3502
+ }),
3497
3503
  step: {
3498
3504
  description: `deploy the usage tail Worker ${PLATFORM_USAGE_TAIL_SCRIPT_NAME} from edge release ${usageTail.tag}, with its held key`,
3499
3505
  destroys: false,
@@ -69,6 +69,9 @@ export interface OrgPlatformReadiness {
69
69
  platformLettermintProjectId: string | null;
70
70
  /** The files host (#171). Files is always on for every org (#80), so `false` always blocks rather than only mattering when a flag is set. */
71
71
  filesReady: boolean;
72
+ /** Existing production service hosts, for alert sender reconciliation. */
73
+ filesAppId?: string;
74
+ jobsAppId?: string;
72
75
  /**
73
76
  * The files host's internal address, which an entry with `uses.files` gets
74
77
  * as `FILES_URL`. `null` or absent until the platform has recorded it.
@@ -137,6 +137,8 @@ edgePortPresent = false) {
137
137
  platformOwnerOrgId: platform.ownerOrgId || null,
138
138
  platformLettermintProjectId: platform.mail.lettermintProjectId ?? null,
139
139
  filesReady: filesApplied && missingKeys.files === null,
140
+ filesAppId: platformRecordedIds[OWN_FILES_RUNNER_SCOPE_KEY],
141
+ jobsAppId: platformRecordedIds[`coolify:jobs:${JOBS_SCHEDULER_KEY}`],
140
142
  filesUrl: filesApplied
141
143
  ? (platformRecordedIds[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] ?? null)
142
144
  : null,
@@ -231,6 +233,8 @@ export function orgPlatformReadinessFor(org, base, orgRecordedIds, heldRefs) {
231
233
  authReady,
232
234
  authIssuer: authReady ? ownHostnames(org).issuerUrl : null,
233
235
  filesReady: filesApplied && filesUrl !== undefined && missingKeys.files === null,
236
+ filesAppId: orgRecordedIds[OWN_FILES_RUNNER_SCOPE_KEY],
237
+ jobsAppId: orgRecordedIds[`coolify:own-jobs:${org.id}`],
234
238
  filesUrl: filesApplied ? (filesUrl ?? null) : null,
235
239
  jobsReady: jobsApplied && jobsUrl !== undefined && missingKeys.jobs === null,
236
240
  ...(jobsApplied && jobsUrl === undefined ? { jobsUrlMissing: true } : {}),
@@ -0,0 +1,36 @@
1
+ import { type DeclaredOrg } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ import type { CoolifyPort } from './executor.js';
4
+ import type { OrgPlatformReadiness } from './platform-org.js';
5
+ export interface ServiceAlertMailWant {
6
+ service: 'files' | 'jobs';
7
+ appId: string;
8
+ from: string;
9
+ tokenRef: string;
10
+ }
11
+ /** Reconcile existing service hosts against live env, including held-token rotations. */
12
+ export declare function planServiceAlertMail(org: DeclaredOrg, readiness: OrgPlatformReadiness, capable: boolean, ownMailKeyHeld: boolean, current?: Partial<Record<'files' | 'jobs', boolean | 'unreadable'>>): {
13
+ steps: DraftStep[];
14
+ blocked: {
15
+ key: string;
16
+ description: string;
17
+ }[];
18
+ };
19
+ export declare function applyServiceAlertMail(input: {
20
+ want: ServiceAlertMailWant;
21
+ ids: Readonly<Record<string, string>>;
22
+ coolify: Pick<CoolifyPort, 'listEnvs' | 'bulkUpsertEnvs' | 'deployAndWait'>;
23
+ resolve(ref: string): Promise<string | null>;
24
+ secret(value: string): void;
25
+ record(key: string, value: string): void;
26
+ forget(key: string): void;
27
+ persist(): Promise<void>;
28
+ timeoutMs?: number;
29
+ }): Promise<void>;
30
+ /** Compare in memory only; no token or token digest is exposed to the planner. */
31
+ export declare function serviceAlertMailCurrent(input: {
32
+ want: ServiceAlertMailWant;
33
+ ids: Readonly<Record<string, string>>;
34
+ coolify: Pick<CoolifyPort, 'listEnvs'>;
35
+ resolve(ref: string): Promise<string | null>;
36
+ }): Promise<boolean>;
@@ -0,0 +1,105 @@
1
+ import { ownsServices } from '../org.js';
2
+ import { withTimeout } from './call-timeout.js';
3
+ import { ownHostnames } from './own-hostnames.js';
4
+ import { orgTarget, platformTarget } from './own-services.js';
5
+ /** Reconcile existing service hosts against live env, including held-token rotations. */
6
+ export function planServiceAlertMail(org, readiness, capable, ownMailKeyHeld, current = {}) {
7
+ const steps = [];
8
+ const blocked = [];
9
+ const own = ownsServices(org);
10
+ if (!own && org.id !== readiness.platformOwnerOrgId)
11
+ return { steps, blocked };
12
+ for (const service of ['files', 'jobs']) {
13
+ const appId = service === 'files' ? readiness.filesAppId : readiness.jobsAppId;
14
+ if (!appId)
15
+ continue; // A later reconcile follows provisioning the service host.
16
+ const ownerMail = service === 'jobs' && own;
17
+ const domain = ownerMail ? ownHostnames(org).mailDomain : readiness.platformApex;
18
+ const held = ownerMail ? ownMailKeyHeld : readiness.mailKeyHeld;
19
+ const key = `${org.id}:${service}`;
20
+ if (current[service] === true)
21
+ continue;
22
+ const reason = current[service] === 'unreadable'
23
+ ? 'the current sender configuration could not be read'
24
+ : !capable
25
+ ? 'the host cannot deliver service alert mail'
26
+ : !domain
27
+ ? 'the sender domain is not declared'
28
+ : !held
29
+ ? 'the sender credential is not held'
30
+ : null;
31
+ if (reason) {
32
+ blocked.push({
33
+ key: `coolify:service-alert-mail:${key}`,
34
+ description: `${org.slug}'s ${service} alert mail is blocked: ${reason}`,
35
+ });
36
+ continue;
37
+ }
38
+ steps.push({
39
+ action: {
40
+ system: 'coolify',
41
+ kind: 'service-alert-mail',
42
+ key,
43
+ desired: {
44
+ service,
45
+ appId,
46
+ from: `noreply@${domain}`,
47
+ tokenRef: ownerMail
48
+ ? orgTarget(org).refs.brokerMailKey
49
+ : platformTarget().refs.brokerMailKey,
50
+ },
51
+ },
52
+ step: {
53
+ description: `reconcile the sender for ${org.slug}'s ${service} alerts`,
54
+ destroys: false,
55
+ migrates: false,
56
+ },
57
+ });
58
+ }
59
+ return { steps, blocked };
60
+ }
61
+ export async function applyServiceAlertMail(input) {
62
+ const { want, coolify } = input;
63
+ const call = (fn) => withTimeout('Service alert mail delivery', input.timeoutMs ?? 30_000, fn);
64
+ const rows = (await call(() => coolify.listEnvs(want.appId))).filter((row) => !row.is_preview);
65
+ // Stage copies are provisioned through stageMailEnv. Never turn a sink host into a live sender.
66
+ if (rows.some((row) => row.key === 'MAIL_SINK_URL' && row.value))
67
+ throw new Error('Service alert mail delivery refuses a stage mail sink.');
68
+ const token = await call(() => input.resolve(want.tokenRef));
69
+ if (!token)
70
+ throw new Error('Service alert mail delivery: the sender credential is not held.');
71
+ input.secret(token);
72
+ const envs = [
73
+ { key: 'MAIL_FROM', value: want.from, literal: true },
74
+ { key: 'LETTERMINT_API_TOKEN', value: token, literal: true },
75
+ ];
76
+ const changed = envs.some((env) => rows.find((row) => row.key === env.key)?.value !== env.value);
77
+ const marker = `service_alert_mail_pending.${want.service}`;
78
+ if (!changed && input.ids[marker] !== want.appId)
79
+ return;
80
+ // Persist before the env write. A failed write or deployment must be retried even if env now matches.
81
+ input.record(marker, want.appId);
82
+ await input.persist();
83
+ if (changed)
84
+ await call(() => coolify.bulkUpsertEnvs(want.appId, envs));
85
+ const deployment = await withTimeout('Service alert mail deployment', 10 * 60_000, () => coolify.deployAndWait(want.appId));
86
+ if (deployment.status !== 'finished')
87
+ throw new Error('Service alert mail deployment did not finish successfully.');
88
+ input.forget(marker);
89
+ await input.persist();
90
+ }
91
+ /** Compare in memory only; no token or token digest is exposed to the planner. */
92
+ export async function serviceAlertMailCurrent(input) {
93
+ const { want } = input;
94
+ if (input.ids[`service_alert_mail_pending.${want.service}`])
95
+ return false;
96
+ return withTimeout('Service alert mail read', 30_000, async () => {
97
+ const rows = (await input.coolify.listEnvs(want.appId)).filter((row) => !row.is_preview);
98
+ if (rows.some((row) => row.key === 'MAIL_SINK_URL' && row.value))
99
+ throw new Error('Service alert mail read refuses a stage mail sink.');
100
+ const token = await input.resolve(want.tokenRef);
101
+ return (Boolean(token) &&
102
+ rows.some((row) => row.key === 'MAIL_FROM' && row.value === want.from) &&
103
+ rows.some((row) => row.key === 'LETTERMINT_API_TOKEN' && row.value === token));
104
+ });
105
+ }
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import { usageTailArtifactUrl } from '../platform.js';
3
- import { PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY, PLATFORM_USAGE_TAIL_KEY, PLATFORM_USAGE_TAIL_KEY_REF, PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY, PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY, PLATFORM_USAGE_TAIL_SCRIPT_NAME, } from './platform-diff.js';
3
+ import { PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY, PLATFORM_USAGE_TAIL_KEY, PLATFORM_USAGE_TAIL_KEY_REF, PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY, PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY, PLATFORM_USAGE_TAIL_SCRIPT_NAME, } from './platform-diff.js';
4
4
  /** The date Cloudflare's Workers runtime compatibility is pinned to; no Workers feature here needs a later one. */
5
5
  const USAGE_TAIL_COMPATIBILITY_DATE = '2026-10-01';
6
6
  /**
@@ -28,6 +28,7 @@ export async function provisionPlatformUsageTail(context, deps) {
28
28
  PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY,
29
29
  PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY,
30
30
  PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY,
31
+ PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY,
31
32
  PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY,
32
33
  ]) {
33
34
  context.recordScopeId(key, '');
@@ -64,7 +65,9 @@ export async function provisionPlatformUsageTail(context, deps) {
64
65
  artifactUrl: usageTailArtifactUrl(usageTail.tag),
65
66
  artifactSha256: usageTail.sha256,
66
67
  compatibilityDate: USAGE_TAIL_COMPATIBILITY_DATE,
67
- bindings: [{ type: 'plain_text', name: 'EDGE_USAGE_URL', text: edgeUrl }],
68
+ bindings: [
69
+ { type: 'plain_text', name: 'EDGE_USAGE_URL', text: `https://${platform.edge.hostname}` },
70
+ ],
68
71
  });
69
72
  // The script is foundry's from here on. Recorded now, not at the end: after
70
73
  // a later failure the next plan must repair this Worker, not take it for a
@@ -84,6 +87,7 @@ export async function provisionPlatformUsageTail(context, deps) {
84
87
  // Last, after every write the applied values depend on.
85
88
  context.recordScopeId(PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY, usageTail.tag);
86
89
  context.recordScopeId(PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY, usageTail.sha256);
90
+ context.recordScopeId(PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY, `https://${platform.edge.hostname}`);
87
91
  return { resourceId: PLATFORM_USAGE_TAIL_SCRIPT_NAME };
88
92
  }
89
93
  /** Rule 1: delivery is tried up to `deliverAttempts` times; the error names the ref only, never the cause. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.77.1",
3
+ "version": "0.78.1",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",