@wtfalch/foundry 0.76.1 → 0.77.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.
Files changed (125) hide show
  1. package/dist/app-canary-worker.d.ts +35 -0
  2. package/dist/app-canary-worker.js +56 -0
  3. package/dist/audit/connector.d.ts +6 -2
  4. package/dist/audit/connector.js +3 -2
  5. package/dist/audit/evidence.d.ts +4 -0
  6. package/dist/audit/evidence.js +14 -2
  7. package/dist/audit/host-store.d.ts +58 -0
  8. package/dist/audit/host-store.js +147 -0
  9. package/dist/audit/host.d.ts +53 -0
  10. package/dist/audit/host.js +294 -0
  11. package/dist/audit/index.d.ts +2 -0
  12. package/dist/audit/index.js +2 -0
  13. package/dist/audit/registry.d.ts +14 -0
  14. package/dist/audit/sealer-role.d.ts +12 -0
  15. package/dist/audit/sealer-role.js +26 -2
  16. package/dist/audit/tick.d.ts +10 -2
  17. package/dist/audit/tick.js +15 -2
  18. package/dist/catalogue/copy-en.js +5 -0
  19. package/dist/catalogue/integrations.js +5 -0
  20. package/dist/entry-rules.d.ts +35 -0
  21. package/dist/entry-rules.js +35 -0
  22. package/dist/hosted-label.js +2 -1
  23. package/dist/index.d.ts +11 -3
  24. package/dist/index.js +6 -1
  25. package/dist/migrations/0072_audit_host.sql +46 -0
  26. package/dist/migrations/0074_platform_app_canary.sql +11 -0
  27. package/dist/modules/ai.js +15 -1
  28. package/dist/modules/assistants.js +3 -0
  29. package/dist/modules/email.d.ts +6 -1
  30. package/dist/modules/email.js +88 -5
  31. package/dist/modules/files.js +79 -2
  32. package/dist/modules/index.js +7 -0
  33. package/dist/modules/jobs.d.ts +5 -1
  34. package/dist/modules/jobs.js +74 -4
  35. package/dist/modules/reporting.d.ts +9 -0
  36. package/dist/modules/reporting.js +29 -0
  37. package/dist/modules/sign-in.js +20 -2
  38. package/dist/modules/types.d.ts +2 -0
  39. package/dist/modules/workspaces.js +3 -0
  40. package/dist/org.d.ts +114 -13
  41. package/dist/org.js +233 -20
  42. package/dist/plan/ai-boule-key-apply.d.ts +65 -0
  43. package/dist/plan/ai-boule-key-apply.js +169 -0
  44. package/dist/plan/ai-boule-key.d.ts +89 -0
  45. package/dist/plan/ai-boule-key.js +195 -0
  46. package/dist/plan/analytics.d.ts +8 -1
  47. package/dist/plan/analytics.js +8 -1
  48. package/dist/plan/app-canary-apply.d.ts +49 -0
  49. package/dist/plan/app-canary-apply.js +232 -0
  50. package/dist/plan/app-canary.d.ts +44 -0
  51. package/dist/plan/app-canary.js +110 -0
  52. package/dist/plan/blue-green-apply.d.ts +6 -2
  53. package/dist/plan/blue-green-apply.js +14 -3
  54. package/dist/plan/broker-entries.js +20 -5
  55. package/dist/plan/company-backups.js +2 -0
  56. package/dist/plan/diff.d.ts +22 -0
  57. package/dist/plan/diff.js +231 -18
  58. package/dist/plan/entry-files-key.d.ts +39 -3
  59. package/dist/plan/entry-files-key.js +103 -6
  60. package/dist/plan/entry-jobs-boule-key-apply.d.ts +65 -0
  61. package/dist/plan/entry-jobs-boule-key-apply.js +192 -0
  62. package/dist/plan/entry-jobs-boule-key.d.ts +59 -0
  63. package/dist/plan/entry-jobs-boule-key.js +141 -0
  64. package/dist/plan/entry-jobs.d.ts +2 -0
  65. package/dist/plan/entry-reporting-key.d.ts +36 -0
  66. package/dist/plan/entry-reporting-key.js +136 -0
  67. package/dist/plan/executor.d.ts +108 -1
  68. package/dist/plan/executor.js +669 -23
  69. package/dist/plan/files-boule-key-apply.d.ts +73 -0
  70. package/dist/plan/files-boule-key-apply.js +223 -0
  71. package/dist/plan/files-boule-key.d.ts +92 -0
  72. package/dist/plan/files-boule-key.js +175 -0
  73. package/dist/plan/hostnames.js +4 -0
  74. package/dist/plan/index.d.ts +4 -0
  75. package/dist/plan/index.js +2 -0
  76. package/dist/plan/jobs-scheduler-http.d.ts +13 -0
  77. package/dist/plan/jobs-scheduler-http.js +39 -0
  78. package/dist/plan/live.d.ts +1 -1
  79. package/dist/plan/locked-hostnames.d.ts +1 -1
  80. package/dist/plan/locked-hostnames.js +4 -1
  81. package/dist/plan/login-host.js +2 -2
  82. package/dist/plan/org-names.js +3 -0
  83. package/dist/plan/own-diff.js +3 -2
  84. package/dist/plan/own-jobs-route.d.ts +5 -2
  85. package/dist/plan/own-jobs-route.js +6 -2
  86. package/dist/plan/own-limits.d.ts +6 -0
  87. package/dist/plan/own-limits.js +1 -0
  88. package/dist/plan/plan-apply.d.ts +24 -0
  89. package/dist/plan/plan-apply.js +39 -4
  90. package/dist/plan/platform-diff.d.ts +2 -0
  91. package/dist/plan/platform-diff.js +15 -1
  92. package/dist/plan/platform-providers.d.ts +9 -0
  93. package/dist/plan/platform-providers.js +24 -0
  94. package/dist/plan/providers-readers.js +25 -1
  95. package/dist/plan/registered-app-env.d.ts +31 -0
  96. package/dist/plan/registered-app-env.js +63 -0
  97. package/dist/plan/registered-app.d.ts +121 -0
  98. package/dist/plan/registered-app.js +262 -0
  99. package/dist/plan/release-host.js +2 -1
  100. package/dist/plan/reporting-boule.d.ts +18 -0
  101. package/dist/plan/reporting-boule.js +44 -0
  102. package/dist/plan/reporting.d.ts +90 -0
  103. package/dist/plan/reporting.js +135 -0
  104. package/dist/plan/stage-env.js +1 -0
  105. package/dist/plan/stage-isolation.d.ts +3 -1
  106. package/dist/plan/stage-isolation.js +20 -2
  107. package/dist/plan/stage-services.d.ts +3 -1
  108. package/dist/plan/stage-services.js +8 -1
  109. package/dist/plan/stage-sink.d.ts +2 -2
  110. package/dist/plan/stage-sink.js +2 -2
  111. package/dist/plan/workspace-client.d.ts +1 -1
  112. package/dist/plan/workspace-client.js +2 -1
  113. package/dist/plan/workspace-sign-in.d.ts +1 -1
  114. package/dist/plan/workspace-sign-in.js +39 -7
  115. package/dist/platform.d.ts +25 -0
  116. package/dist/platform.js +20 -0
  117. package/dist/service-labels.d.ts +8 -0
  118. package/dist/service-labels.js +8 -0
  119. package/dist/slug.d.ts +1 -1
  120. package/dist/slug.js +5 -2
  121. package/dist/store/postgres.js +13 -0
  122. package/dist/store/schema.d.ts +485 -2
  123. package/dist/store/schema.js +38 -1
  124. package/dist/worker-app-config.js +2 -2
  125. package/package.json +1 -1
@@ -0,0 +1,232 @@
1
+ import { APPS_DOMAIN } from '../org.js';
2
+ import { appCanaryArtifactUrl } from '../platform.js';
3
+ import { WORKER_APP_DNS_RECORD, withCallTimeout } from '../worker-app-upload.js';
4
+ import { PLATFORM_APP_CANARY_APPLIED_SHA256_SCOPE_KEY, PLATFORM_APP_CANARY_APPLIED_TAG_SCOPE_KEY, PLATFORM_APP_CANARY_DNS_SCOPE_KEY, PLATFORM_APP_CANARY_HOSTNAME, PLATFORM_APP_CANARY_HOSTNAME_SCOPE_KEY, PLATFORM_APP_CANARY_REMOVED_SCOPE_KEY, PLATFORM_APP_CANARY_RESOURCE_KEY, PLATFORM_APP_CANARY_ROUTE_SCOPE_KEY, PLATFORM_APP_CANARY_SCRIPT_NAME, appCanaryDeployed, appCanaryRecorded, } from './app-canary.js';
5
+ import { PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY } from './platform-diff.js';
6
+ /** The date Cloudflare's Workers runtime compatibility is pinned to; the canary needs nothing later. */
7
+ const APP_CANARY_COMPATIBILITY_DATE = '2026-10-01';
8
+ /** One outside call's limit: 60 s. */
9
+ const APP_CANARY_CALL_TIMEOUT_MS = 60_000;
10
+ /** How many times `GET https://<host>/` is asked before the step fails (rule 5: never forever). */
11
+ const APP_CANARY_PROBE_ATTEMPTS = 6;
12
+ /** Waits between probes: the route and record can take seconds to answer. */
13
+ const APP_CANARY_PROBE_INTERVAL_MS = 5_000;
14
+ /** One probe's limit. */
15
+ const APP_CANARY_PROBE_TIMEOUT_MS = 10_000;
16
+ const AT = 'platform:app-canary';
17
+ const PATTERN = `${PLATFORM_APP_CANARY_HOSTNAME}/*`;
18
+ function parse(raw) {
19
+ if (!raw)
20
+ return null;
21
+ try {
22
+ return JSON.parse(raw);
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ const isOurRecord = (r) => r.type === WORKER_APP_DNS_RECORD.type && r.content === WORKER_APP_DNS_RECORD.content;
29
+ async function defaultProbe(url) {
30
+ const response = await fetch(url, {
31
+ method: 'GET',
32
+ redirect: 'manual',
33
+ signal: AbortSignal.timeout(APP_CANARY_PROBE_TIMEOUT_MS),
34
+ });
35
+ await response.body?.cancel();
36
+ return response.status;
37
+ }
38
+ /**
39
+ * Why the canary cannot be planned yet, read live (rule 2), or `undefined`.
40
+ * A script, route or DNS record on the canary's name that foundry did not
41
+ * make is never taken over: the plan says so and plans nothing, rather than a
42
+ * step that throws on every apply. A missing apps zone is the same.
43
+ */
44
+ export async function appCanaryBlockedReason(deps, platform, recordedIds) {
45
+ const enabled = platform.appCanary?.enabled === true;
46
+ if (!enabled && !appCanaryRecorded(recordedIds))
47
+ return undefined;
48
+ if (!deps) {
49
+ return 'the app-layer canary is blocked, not planned: the host built the platform executor without its app-canary dependencies';
50
+ }
51
+ const edgeUrl = recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
52
+ if (!enabled || !edgeUrl || !platform.appCanary?.tag)
53
+ return undefined;
54
+ const ms = deps.callTimeoutMs ?? APP_CANARY_CALL_TIMEOUT_MS;
55
+ const call = (label, task) => withCallTimeout(ms, `${AT}: ${label}`, task);
56
+ try {
57
+ const zones = await deps.zones(edgeUrl);
58
+ const zone = await call('find the apps zone', () => zones.findZoneByApex(APPS_DOMAIN));
59
+ if (!zone) {
60
+ return `the app-layer canary is blocked, not planned: edge has no zone for ${APPS_DOMAIN}`;
61
+ }
62
+ const workers = await deps.workers(edgeUrl);
63
+ const route = await call('find the route', () => workers.findRoute(zone.id, PATTERN));
64
+ if (route && route.script !== PLATFORM_APP_CANARY_SCRIPT_NAME) {
65
+ return `the app-layer canary is blocked, not planned: the route ${PATTERN} runs another Worker; remove it by hand`;
66
+ }
67
+ if (!appCanaryDeployed(recordedIds)) {
68
+ if (await call('check the script name', () => workers.scriptExists(PLATFORM_APP_CANARY_SCRIPT_NAME))) {
69
+ return `the app-layer canary is blocked, not planned: a Worker named ${PLATFORM_APP_CANARY_SCRIPT_NAME} already exists at Cloudflare and foundry did not make it; remove it by hand`;
70
+ }
71
+ }
72
+ if (!recordedIds[PLATFORM_APP_CANARY_DNS_SCOPE_KEY]) {
73
+ const dns = await deps.dns(edgeUrl);
74
+ const found = await call('look for the DNS record', () => dns.findDnsRecord(zone.name, PLATFORM_APP_CANARY_HOSTNAME));
75
+ if (found.length > 0 && !found.some(isOurRecord)) {
76
+ return `the app-layer canary is blocked, not planned: ${PLATFORM_APP_CANARY_HOSTNAME} already has a DNS record foundry did not make; remove it by hand`;
77
+ }
78
+ }
79
+ }
80
+ catch {
81
+ return 'the app-layer canary is blocked, not planned: edge could not be asked what is on the canary name';
82
+ }
83
+ return undefined;
84
+ }
85
+ /**
86
+ * foundry#1133: deploys the canary on the managed-app layer and records the
87
+ * hostname Archon's uptime probes, last, once it answers 200. The script is
88
+ * recorded right after its upload (a later failure leaves a half-made canary
89
+ * foundry owns, which the next apply repairs); the record and the route are
90
+ * recorded as each is made. Disabled, it clears the hostname first, so Archon
91
+ * stops probing, then removes the route, the record and the script.
92
+ */
93
+ export async function provisionPlatformAppCanary(context, deps) {
94
+ const recorded = context.recordedIds();
95
+ const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
96
+ if (!edgeUrl) {
97
+ throw new Error(`${AT}: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); the canary is deployed through it`);
98
+ }
99
+ const ms = deps.callTimeoutMs ?? APP_CANARY_CALL_TIMEOUT_MS;
100
+ const call = (label, task) => withCallTimeout(ms, `${AT}: ${label}`, task);
101
+ const workers = await deps.workers(edgeUrl);
102
+ const zones = await deps.zones(edgeUrl);
103
+ const dns = await deps.dns(edgeUrl);
104
+ const canary = context.platform.appCanary;
105
+ if (!canary?.enabled) {
106
+ await removeAppCanary(context, { workers, zones, dns, call });
107
+ return {};
108
+ }
109
+ if (!canary.tag || !canary.sha256) {
110
+ throw new Error(`${AT}: no artifact is pinned (appCanary.tag, appCanary.sha256)`);
111
+ }
112
+ // The zone and the route first: a foreign route is refused before anything is uploaded.
113
+ const zone = await call('find the apps zone', () => zones.findZoneByApex(APPS_DOMAIN));
114
+ if (!zone)
115
+ throw new Error(`${AT}: edge has no zone for ${APPS_DOMAIN}`);
116
+ const existing = await call('find the route', () => workers.findRoute(zone.id, PATTERN));
117
+ if (existing && existing.script !== PLATFORM_APP_CANARY_SCRIPT_NAME) {
118
+ throw new Error(`${AT}: the route ${PATTERN} runs another Worker; foundry does not take it over`);
119
+ }
120
+ // ---- the script ----------------------------------------------------------
121
+ await call('upload the script', () => workers.uploadScript({
122
+ name: PLATFORM_APP_CANARY_SCRIPT_NAME,
123
+ artifactUrl: appCanaryArtifactUrl(canary.tag),
124
+ artifactSha256: canary.sha256,
125
+ compatibilityDate: APP_CANARY_COMPATIBILITY_DATE,
126
+ bindings: [],
127
+ }));
128
+ context.recordScopeId(PLATFORM_APP_CANARY_RESOURCE_KEY, PLATFORM_APP_CANARY_SCRIPT_NAME);
129
+ context.recordScopeId(PLATFORM_APP_CANARY_REMOVED_SCOPE_KEY, '');
130
+ await context.persist?.();
131
+ // ---- the host's record ---------------------------------------------------
132
+ // `companyincluded.app` has no wildcard, and edge writes a company route's
133
+ // record itself but not a platform route's, so the host would not resolve.
134
+ const recordedDns = parse(context.recordedIds()[PLATFORM_APP_CANARY_DNS_SCOPE_KEY]);
135
+ if (recordedDns?.zone !== zone.name) {
136
+ const found = await call('look for the DNS record', () => dns.findDnsRecord(zone.name, PLATFORM_APP_CANARY_HOSTNAME));
137
+ const ours = found.find(isOurRecord);
138
+ let id;
139
+ if (ours)
140
+ id = ours.id;
141
+ else if (found.length > 0) {
142
+ throw new Error(`${AT}: ${PLATFORM_APP_CANARY_HOSTNAME} already has a DNS record foundry did not make; it does not replace it`);
143
+ }
144
+ else {
145
+ id = (await call('create the DNS record', () => dns.createDnsRecord(zone.name, {
146
+ ...WORKER_APP_DNS_RECORD,
147
+ name: PLATFORM_APP_CANARY_HOSTNAME,
148
+ proxied: true,
149
+ ttl: 1,
150
+ }))).id;
151
+ }
152
+ context.recordScopeId(PLATFORM_APP_CANARY_DNS_SCOPE_KEY, JSON.stringify({ zone: zone.name, id }));
153
+ await context.persist?.();
154
+ }
155
+ // ---- the route -----------------------------------------------------------
156
+ const routeId = existing?.id ??
157
+ (await call('create the route', () => workers.createRoute(zone.id, PATTERN, PLATFORM_APP_CANARY_SCRIPT_NAME))).id;
158
+ context.recordScopeId(PLATFORM_APP_CANARY_ROUTE_SCOPE_KEY, JSON.stringify({ zoneId: zone.id, id: routeId }));
159
+ await context.persist?.();
160
+ // ---- it answers ----------------------------------------------------------
161
+ await waitForAnswer(deps);
162
+ // Last: Archon probes the hostname only once it is here.
163
+ context.recordScopeId(PLATFORM_APP_CANARY_APPLIED_TAG_SCOPE_KEY, canary.tag);
164
+ context.recordScopeId(PLATFORM_APP_CANARY_APPLIED_SHA256_SCOPE_KEY, canary.sha256);
165
+ context.recordScopeId(PLATFORM_APP_CANARY_HOSTNAME_SCOPE_KEY, PLATFORM_APP_CANARY_HOSTNAME);
166
+ return { resourceId: PLATFORM_APP_CANARY_SCRIPT_NAME };
167
+ }
168
+ /** `GET https://<host>/` until it answers 200, a bounded number of times; the error is fixed text. */
169
+ async function waitForAnswer(deps) {
170
+ const probe = deps.probe ?? defaultProbe;
171
+ const attempts = deps.probeAttempts ?? APP_CANARY_PROBE_ATTEMPTS;
172
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
173
+ let last = 'no answer';
174
+ for (let attempt = 1;; attempt += 1) {
175
+ try {
176
+ const status = await probe(`https://${PLATFORM_APP_CANARY_HOSTNAME}/`);
177
+ if (status === 200)
178
+ return;
179
+ last = `status ${status}`;
180
+ }
181
+ catch {
182
+ last = 'no answer';
183
+ }
184
+ if (attempt >= attempts) {
185
+ throw new Error(`${AT}: https://${PLATFORM_APP_CANARY_HOSTNAME}/ did not answer 200 after ${attempts} tries (${last}); the hostname is not recorded, so the next apply tries again`);
186
+ }
187
+ await sleep(APP_CANARY_PROBE_INTERVAL_MS);
188
+ }
189
+ }
190
+ async function removeAppCanary(context, ports) {
191
+ const { workers, zones, dns, call } = ports;
192
+ // First, so Archon stops probing a canary that is about to go.
193
+ context.recordScopeId(PLATFORM_APP_CANARY_HOSTNAME_SCOPE_KEY, '');
194
+ context.recordScopeId(PLATFORM_APP_CANARY_APPLIED_TAG_SCOPE_KEY, '');
195
+ context.recordScopeId(PLATFORM_APP_CANARY_APPLIED_SHA256_SCOPE_KEY, '');
196
+ await context.persist?.();
197
+ const recorded = context.recordedIds();
198
+ const route = parse(recorded[PLATFORM_APP_CANARY_ROUTE_SCOPE_KEY]);
199
+ const dnsRecord = parse(recorded[PLATFORM_APP_CANARY_DNS_SCOPE_KEY]);
200
+ // A route made and not yet recorded is found on its pattern, and only when it runs the canary.
201
+ let zone;
202
+ const apexZone = async () => {
203
+ zone ??= await call('find the apps zone', () => zones.findZoneByApex(APPS_DOMAIN));
204
+ return zone;
205
+ };
206
+ const routes = route ? [route] : [];
207
+ if (!route) {
208
+ const z = await apexZone();
209
+ const found = z
210
+ ? await call('find the route', () => workers.findRoute(z.id, PATTERN))
211
+ : undefined;
212
+ if (z && found?.script === PLATFORM_APP_CANARY_SCRIPT_NAME)
213
+ routes.push({ zoneId: z.id, id: found.id });
214
+ }
215
+ for (const r of routes) {
216
+ await call('delete the route', () => workers.deleteRoute(r.zoneId, r.id));
217
+ }
218
+ context.recordScopeId(PLATFORM_APP_CANARY_ROUTE_SCOPE_KEY, '');
219
+ await context.persist?.();
220
+ // Only the record foundry made: `AAAA 100::` on the canary's host.
221
+ const zoneName = dnsRecord?.zone ?? (await apexZone())?.name;
222
+ if (zoneName) {
223
+ const found = await call('look for the DNS record', () => dns.findDnsRecord(zoneName, PLATFORM_APP_CANARY_HOSTNAME));
224
+ for (const r of found.filter(isOurRecord)) {
225
+ await call('delete the DNS record', () => dns.deleteDnsRecord(zoneName, r.id));
226
+ }
227
+ }
228
+ context.recordScopeId(PLATFORM_APP_CANARY_DNS_SCOPE_KEY, '');
229
+ await context.persist?.();
230
+ await call('delete the script', () => workers.deleteScript(PLATFORM_APP_CANARY_SCRIPT_NAME));
231
+ context.recordScopeId(PLATFORM_APP_CANARY_REMOVED_SCOPE_KEY, '1');
232
+ }
@@ -0,0 +1,44 @@
1
+ import type { DeclaredPlatform } from '../platform.js';
2
+ import type { DraftStep } from './diff.js';
3
+ /**
4
+ * foundry#1133: the app-layer canary, one `cloudflare:worker` step keyed
5
+ * {@link PLATFORM_APP_CANARY_KEY}. `runPlatformSteps` records the script name
6
+ * under `cloudflare:worker:platform:app-canary` once it succeeds.
7
+ */
8
+ export declare const PLATFORM_APP_CANARY_KEY = "platform:app-canary";
9
+ /** The Worker script's fixed name at Cloudflare, on the platform's own edge organisation. */
10
+ export declare const PLATFORM_APP_CANARY_SCRIPT_NAME = "uptime-canary";
11
+ /** The canary's first label under {@link APPS_DOMAIN}; locked for companies (`PLATFORM_APP_LABELS`). */
12
+ export declare const PLATFORM_APP_CANARY_LABEL = "canary";
13
+ /** Where the canary answers: one label under the apps zone, as archon#812 requires. */
14
+ export declare const PLATFORM_APP_CANARY_HOSTNAME = "canary.companyincluded.app";
15
+ /**
16
+ * The hostname Archon's uptime probes (`GET /`, 200). Recorded last, only once
17
+ * the canary answered 200 there; cleared first when the canary is removed.
18
+ * Archon reads exactly this key.
19
+ */
20
+ export declare const PLATFORM_APP_CANARY_HOSTNAME_SCOPE_KEY = "platform_app_canary_hostname";
21
+ /** `{ zoneId, id }` of the route `canary.companyincluded.app/*`. */
22
+ export declare const PLATFORM_APP_CANARY_ROUTE_SCOPE_KEY = "app_canary_route";
23
+ /** `{ zone, id }` of the host's proxied `AAAA 100::` record (the zone has no wildcard). */
24
+ export declare const PLATFORM_APP_CANARY_DNS_SCOPE_KEY = "app_canary_dns";
25
+ export declare const PLATFORM_APP_CANARY_APPLIED_TAG_SCOPE_KEY = "app_canary_applied_tag";
26
+ export declare const PLATFORM_APP_CANARY_APPLIED_SHA256_SCOPE_KEY = "app_canary_applied_sha256";
27
+ export declare const PLATFORM_APP_CANARY_REMOVED_SCOPE_KEY = "app_canary_removed";
28
+ /** The scope id of the script itself, written by `runPlatformSteps` from the step's `resourceId`. */
29
+ export declare const PLATFORM_APP_CANARY_RESOURCE_KEY = "cloudflare:worker:platform:app-canary";
30
+ /** Whether the script is recorded as foundry's and not removed since. */
31
+ export declare function appCanaryDeployed(recordedIds: Readonly<Record<string, string>>): boolean;
32
+ /** Whether anything of the canary is recorded that disabling it must remove. */
33
+ export declare function appCanaryRecorded(recordedIds: Readonly<Record<string, string>>): boolean;
34
+ /**
35
+ * The canary step. Blocked, not planned (rule 2), while no artifact is
36
+ * pinned, edge is not applied, or the executor lacks its dependencies or
37
+ * found something in the way (`executorBlocked`). Planned again when the pin
38
+ * changed or a run never got to recording the hostname. When the platform no
39
+ * longer enables it, a destructive step removes whatever of it is recorded.
40
+ */
41
+ export declare function planAppCanary(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>, executorBlocked: string | undefined, edgeApplied: boolean): {
42
+ step: DraftStep | null;
43
+ blocked: string[];
44
+ };
@@ -0,0 +1,110 @@
1
+ import { APPS_DOMAIN } from '../org.js';
2
+ /**
3
+ * foundry#1133: the app-layer canary, one `cloudflare:worker` step keyed
4
+ * {@link PLATFORM_APP_CANARY_KEY}. `runPlatformSteps` records the script name
5
+ * under `cloudflare:worker:platform:app-canary` once it succeeds.
6
+ */
7
+ export const PLATFORM_APP_CANARY_KEY = 'platform:app-canary';
8
+ /** The Worker script's fixed name at Cloudflare, on the platform's own edge organisation. */
9
+ export const PLATFORM_APP_CANARY_SCRIPT_NAME = 'uptime-canary';
10
+ /** The canary's first label under {@link APPS_DOMAIN}; locked for companies (`PLATFORM_APP_LABELS`). */
11
+ export const PLATFORM_APP_CANARY_LABEL = 'canary';
12
+ /** Where the canary answers: one label under the apps zone, as archon#812 requires. */
13
+ export const PLATFORM_APP_CANARY_HOSTNAME = `${PLATFORM_APP_CANARY_LABEL}.${APPS_DOMAIN}`;
14
+ /**
15
+ * The hostname Archon's uptime probes (`GET /`, 200). Recorded last, only once
16
+ * the canary answered 200 there; cleared first when the canary is removed.
17
+ * Archon reads exactly this key.
18
+ */
19
+ export const PLATFORM_APP_CANARY_HOSTNAME_SCOPE_KEY = 'platform_app_canary_hostname';
20
+ /** `{ zoneId, id }` of the route `canary.companyincluded.app/*`. */
21
+ export const PLATFORM_APP_CANARY_ROUTE_SCOPE_KEY = 'app_canary_route';
22
+ /** `{ zone, id }` of the host's proxied `AAAA 100::` record (the zone has no wildcard). */
23
+ export const PLATFORM_APP_CANARY_DNS_SCOPE_KEY = 'app_canary_dns';
24
+ export const PLATFORM_APP_CANARY_APPLIED_TAG_SCOPE_KEY = 'app_canary_applied_tag';
25
+ export const PLATFORM_APP_CANARY_APPLIED_SHA256_SCOPE_KEY = 'app_canary_applied_sha256';
26
+ export const PLATFORM_APP_CANARY_REMOVED_SCOPE_KEY = 'app_canary_removed';
27
+ /** The scope id of the script itself, written by `runPlatformSteps` from the step's `resourceId`. */
28
+ export const PLATFORM_APP_CANARY_RESOURCE_KEY = `cloudflare:worker:${PLATFORM_APP_CANARY_KEY}`;
29
+ /** Whether the script is recorded as foundry's and not removed since. */
30
+ export function appCanaryDeployed(recordedIds) {
31
+ return (Boolean(recordedIds[PLATFORM_APP_CANARY_RESOURCE_KEY]) &&
32
+ !recordedIds[PLATFORM_APP_CANARY_REMOVED_SCOPE_KEY]);
33
+ }
34
+ /** Whether anything of the canary is recorded that disabling it must remove. */
35
+ export function appCanaryRecorded(recordedIds) {
36
+ return (appCanaryDeployed(recordedIds) ||
37
+ Boolean(recordedIds[PLATFORM_APP_CANARY_ROUTE_SCOPE_KEY]) ||
38
+ Boolean(recordedIds[PLATFORM_APP_CANARY_DNS_SCOPE_KEY]) ||
39
+ Boolean(recordedIds[PLATFORM_APP_CANARY_HOSTNAME_SCOPE_KEY]));
40
+ }
41
+ const BLOCKED = 'the app-layer canary is blocked, not planned';
42
+ /**
43
+ * The canary step. Blocked, not planned (rule 2), while no artifact is
44
+ * pinned, edge is not applied, or the executor lacks its dependencies or
45
+ * found something in the way (`executorBlocked`). Planned again when the pin
46
+ * changed or a run never got to recording the hostname. When the platform no
47
+ * longer enables it, a destructive step removes whatever of it is recorded.
48
+ */
49
+ export function planAppCanary(platform, recordedIds, executorBlocked, edgeApplied) {
50
+ const canary = platform.appCanary;
51
+ const action = (desired) => ({
52
+ system: 'cloudflare',
53
+ kind: 'worker',
54
+ key: PLATFORM_APP_CANARY_KEY,
55
+ desired,
56
+ });
57
+ if (!canary?.enabled) {
58
+ if (!appCanaryRecorded(recordedIds))
59
+ return { step: null, blocked: [] };
60
+ if (!edgeApplied) {
61
+ return {
62
+ step: null,
63
+ blocked: [`${BLOCKED}: its removal goes through edge, which is not applied`],
64
+ };
65
+ }
66
+ if (executorBlocked)
67
+ return { step: null, blocked: [executorBlocked] };
68
+ return {
69
+ step: {
70
+ action: action({ remove: true }),
71
+ step: {
72
+ description: `remove the app-layer canary ${PLATFORM_APP_CANARY_SCRIPT_NAME}, its route and record on ${PLATFORM_APP_CANARY_HOSTNAME}, and the hostname Archon's uptime probes`,
73
+ destroys: true,
74
+ migrates: false,
75
+ },
76
+ },
77
+ blocked: [],
78
+ };
79
+ }
80
+ if (!canary.tag || !canary.sha256) {
81
+ return {
82
+ step: null,
83
+ blocked: [
84
+ `${BLOCKED}: no artifact is pinned; declare appCanary.tag and appCanary.sha256 (appCanaryShippedPin() gives the ones this foundry ships)`,
85
+ ],
86
+ };
87
+ }
88
+ if (!edgeApplied) {
89
+ return { step: null, blocked: [`${BLOCKED}: platform:edge has not been applied yet`] };
90
+ }
91
+ if (executorBlocked)
92
+ return { step: null, blocked: [executorBlocked] };
93
+ const applied = appCanaryDeployed(recordedIds) &&
94
+ recordedIds[PLATFORM_APP_CANARY_APPLIED_TAG_SCOPE_KEY] === canary.tag &&
95
+ recordedIds[PLATFORM_APP_CANARY_APPLIED_SHA256_SCOPE_KEY] === canary.sha256 &&
96
+ recordedIds[PLATFORM_APP_CANARY_HOSTNAME_SCOPE_KEY] === PLATFORM_APP_CANARY_HOSTNAME;
97
+ if (applied)
98
+ return { step: null, blocked: [] };
99
+ return {
100
+ step: {
101
+ action: action({ gitRef: canary.tag, sha256: canary.sha256 }),
102
+ step: {
103
+ description: `deploy the app-layer canary ${PLATFORM_APP_CANARY_SCRIPT_NAME} (${canary.tag}) on ${PLATFORM_APP_CANARY_HOSTNAME}, with no Access gate, and record the hostname once it answers`,
104
+ destroys: false,
105
+ migrates: false,
106
+ },
107
+ },
108
+ blocked: [],
109
+ };
110
+ }
@@ -61,10 +61,12 @@ export interface BlueGreenApplyDeps {
61
61
  guardInputs?: readonly string[];
62
62
  /**
63
63
  * The company the stage mail sink belongs to (its name is `<slug>-stage-sink`). Absent: no sink is made and
64
- * stage holds no mail env at all, so the copy stays refused.
64
+ * stage holds no mail env at all, so the copy stays refused. `mailAppKey`, for an entry that sends mail, is
65
+ * the `MAIL_APP_KEY` stage's sends are tagged with (`<key>-work`); it is written with the sink's env.
65
66
  */
66
67
  stageSink?: {
67
68
  slug: string;
69
+ mailAppKey?: string;
68
70
  };
69
71
  }
70
72
  /**
@@ -119,7 +121,9 @@ export declare function applyBlueGreenWeights(deps: LbConfigDeps, subject: BlueG
119
121
  }): Promise<void>;
120
122
  /**
121
123
  * The primary blue's env for another color: the database host swapped when the server is not the primary, and
122
- * never the reporting names or the actions key, which differ by design. The structure apply and a deploy both
124
+ * never the color's own reporting names (`isColorReportingEnv`: color, release, health token) or the actions
125
+ * key, which differ by design. The reporting backend's address and keys (`REPORTING_URL`, `REPORTING_SITE_KEY`,
126
+ * `REPORTING_OPERATOR_KEY`) are the app's, not the color's, so they are copied like any other env. The structure apply and a deploy both
123
127
  * copy through this, so a color is never deployed with an older copy than the structure apply wrote.
124
128
  */
125
129
  export declare function copiedEnv(deps: {
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from 'node:crypto';
2
+ import { MAIL_APP_KEY_ENV } from '../org.js';
2
3
  import { ACTIONS_KEY_ENV, COLORS, LB_CONFIG_PATH, bgKey, colorAppName, colorBackend, formatWeights, lbAppName, lbDockerfile, parseWeights, prodResources, renderLbConfig, reportingEnv, stageAppName, stageAppUrl, stageKindOf, stageResources, structureHash, } from './blue-green.js';
3
4
  import { recordBuiltCommit } from './build-record.js';
4
5
  import { withTimeout } from './call-timeout.js';
@@ -7,6 +8,7 @@ import { waitForNewDeployment } from './deploy-health.js';
7
8
  import { commandHealthCheckSettings, wgetProbe } from './health-check-settings.js';
8
9
  import { ENTRY_CONTAINER_LABELS } from './proxy-labels.js';
9
10
  import { remoteDatabaseAddress, remoteDatabaseHost } from './remote-database.js';
11
+ import { isColorReportingEnv } from './reporting.js';
10
12
  import { STAGE_SIGN_IN_NAMES, buildStageEnv } from './stage-env.js';
11
13
  import { buildStageProbes, runStageProbes, stageProbesRecord, } from './stage-guards.js';
12
14
  import { assertStageIsolated, envClass, stageIsolationHash, } from './stage-isolation.js';
@@ -163,7 +165,9 @@ async function writeLbConfig(deps, subject, server, lbId, weights) {
163
165
  }
164
166
  /**
165
167
  * The primary blue's env for another color: the database host swapped when the server is not the primary, and
166
- * never the reporting names or the actions key, which differ by design. The structure apply and a deploy both
168
+ * never the color's own reporting names (`isColorReportingEnv`: color, release, health token) or the actions
169
+ * key, which differ by design. The reporting backend's address and keys (`REPORTING_URL`, `REPORTING_SITE_KEY`,
170
+ * `REPORTING_OPERATOR_KEY`) are the app's, not the color's, so they are copied like any other env. The structure apply and a deploy both
167
171
  * copy through this, so a color is never deployed with an older copy than the structure apply wrote.
168
172
  */
169
173
  export function copiedEnv(deps, subject, source, server) {
@@ -175,7 +179,7 @@ export function copiedEnv(deps, subject, source, server) {
175
179
  for (const env of source) {
176
180
  if (env.is_preview ||
177
181
  env.value === undefined ||
178
- env.key.startsWith('REPORTING_') ||
182
+ isColorReportingEnv(env.key) ||
179
183
  env.key === ACTIONS_KEY_ENV) {
180
184
  continue;
181
185
  }
@@ -482,7 +486,14 @@ export async function ensureStage(deps, subject, serverUuid) {
482
486
  }
483
487
  // The sink's mail env, only once the sink app is deployed. Before that stage holds no mail env at all.
484
488
  const sinkApplied = deps.stageSink && deps.recorded()[STAGE_SINK_APPLIED_SCOPE_KEY];
485
- const mail = deps.stageSink && sinkApplied ? stageMailEnv(deps.stageSink) : [];
489
+ const mail = deps.stageSink && sinkApplied
490
+ ? [
491
+ ...stageMailEnv(deps.stageSink),
492
+ ...(deps.stageSink.mailAppKey
493
+ ? [{ key: MAIL_APP_KEY_ENV, value: deps.stageSink.mailAppKey }]
494
+ : []),
495
+ ]
496
+ : [];
486
497
  const stageHas = new Map((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
487
498
  const mailChanged = [...mail, ...signInEnv].some((e) => stageHas.get(e.key) !== e.value);
488
499
  stageEnv.push(...mail);
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { isFoundryOwnedBrokerEntryName, } from '../org.js';
2
+ import { isFoundryOwnedBrokerEntryName, signsInBoth, signsInCustomers, } from '../org.js';
3
3
  import { entryClientIdScopeKey } from './agora.js';
4
4
  import { MCP_DATABASE_URL_ENV, MCP_OAUTH_SECRET_ENV } from './broker-mcp.js';
5
5
  import { BROKER_WORKSPACES_DATABASE_ENV, wantedWorkspaces, workspacesUrl, } from './entry-workspaces.js';
@@ -7,6 +7,7 @@ import { entryHostnames } from './hostnames.js';
7
7
  import { loginHostnameFor } from './login-host.js';
8
8
  import { PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, parseBrokerDeployRecord, } from './platform-diff.js';
9
9
  import { archonOrgConflicts, brokerDeployNeeded, brokerDeployRecordValue, missingBrokerInputs, validateBrokerAppEntry, } from './platform-providers.js';
10
+ import { registeredAppEnv } from './registered-app-env.js';
10
11
  /**
11
12
  * F5 (https://github.com/wtfalch/foundry/issues/314): an entry with
12
13
  * `uses.auth` gets one entry in the broker's `AUTH_APPS`. The org executor has
@@ -68,11 +69,23 @@ export function entryAuthEnv(org, entry, recorded, issuer) {
68
69
  const auth = entry.uses?.auth;
69
70
  if (!auth)
70
71
  return {};
71
- const organizationId = auth.users === 'customers' ? recorded[`zitadel:customers:${org.id}`] : recorded.zitadel_org;
72
+ const organizationId = signsInCustomers(auth)
73
+ ? recorded[`zitadel:customers:${org.id}`]
74
+ : recorded.zitadel_org;
75
+ // An entry both sign in to holds what the broker answered its registration (ADR 0003), the team's client too.
76
+ const registered = signsInBoth(auth) ? registeredAppEnv(recorded, entry.key) : null;
72
77
  const values = {
73
78
  AUTH_ORGANIZATION_ID: organizationId,
74
- AUTH_CLIENT_ID: recorded[entryClientIdScopeKey(entry.key)],
79
+ AUTH_CLIENT_ID: signsInBoth(auth)
80
+ ? registered?.AUTH_CLIENT_ID
81
+ : recorded[entryClientIdScopeKey(entry.key)],
75
82
  AUTH_ISSUER: issuer ?? undefined,
83
+ ...(registered
84
+ ? {
85
+ AUTH_TEAM_CLIENT_ID: registered.AUTH_TEAM_CLIENT_ID,
86
+ AUTH_TEAM_ORGANIZATION_ID: registered.AUTH_TEAM_ORGANIZATION_ID,
87
+ }
88
+ : {}),
76
89
  };
77
90
  const mcp = entryMcp(org, entry, recorded);
78
91
  const mcpValues = mcp
@@ -93,12 +106,14 @@ export function entryAuthEnvAppliedScopeKey(entryKey) {
93
106
  }
94
107
  export function wantedBrokerEntry(org, entry, recorded) {
95
108
  const auth = entry.uses?.auth;
96
- const organizationId = auth.users === 'customers' ? recorded[`zitadel:customers:${org.id}`] : recorded.zitadel_org;
109
+ const organizationId = signsInCustomers(auth)
110
+ ? recorded[`zitadel:customers:${org.id}`]
111
+ : recorded.zitadel_org;
97
112
  const clientId = recorded[entryClientIdScopeKey(entry.key)];
98
113
  const mailDomain = recorded.mail_domain;
99
114
  const missing = [];
100
115
  if (!organizationId) {
101
- missing.push(auth.users === 'customers'
116
+ missing.push(signsInCustomers(auth)
102
117
  ? { what: 'the customers organisation id', producedBy: ['zitadel', 'customers'] }
103
118
  : { what: "the org's ZITADEL org id", producedBy: ['zitadel', 'issuer'] });
104
119
  }
@@ -4,6 +4,7 @@ import { BOULE_DATABASE_SCOPE_KEY } from './boule.js';
4
4
  import { CHAT_REKEY_MAS_DB_SCOPE_KEY, CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY } from './executor.js';
5
5
  import { MAIL_DATABASE_NAME, MAIL_DATABASE_SCOPE_KEY } from './mail-store.js';
6
6
  import { serviceOnServer } from './org-postgres.js';
7
+ import { REPORTING_DATABASE_SCOPE_KEY } from './reporting.js';
7
8
  import { signinDbBackupMissing } from './signin-db-backup.js';
8
9
  import { SIGNIN_DB_TLS_SCOPE_KEY } from './signin-db-tls.js';
9
10
  /**
@@ -31,6 +32,7 @@ export function companyDatabaseKeys(org) {
31
32
  chat_mas: 'coolify_chat_mas_database',
32
33
  boule: BOULE_DATABASE_SCOPE_KEY,
33
34
  analytics: ANALYTICS_DATABASE_SCOPE_KEY,
35
+ reporting: REPORTING_DATABASE_SCOPE_KEY,
34
36
  chat_rekey_synapse: CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY,
35
37
  chat_rekey_mas: CHAT_REKEY_MAS_DB_SCOPE_KEY,
36
38
  postgres: ORG_POSTGRES_SERVER_SCOPE_KEY,
@@ -66,6 +66,8 @@ export interface PlanOrgOptions {
66
66
  zitadelOrgMetadata?: boolean;
67
67
  /** F5: the executor was built with a `broker` port. */
68
68
  broker?: boolean;
69
+ /** ADR 0003: the executor was built with a `registrarApi` port, so an entry both sign in to can be registered. */
70
+ registrarApi?: boolean;
69
71
  /** The broker the org's entries are written to has `WORKSPACES_DATABASE_URL`, or a URL is held to give it. Absent reads as false. */
70
72
  brokerWorkspacesDatabase?: boolean;
71
73
  /** F5b: the broker has none, and the host can make the database and hold its URL (`canProvisionWorkspacesDatabase`). Absent reads as false. */
@@ -86,6 +88,16 @@ export interface PlanOrgOptions {
86
88
  keysRegistrarUrl?: string;
87
89
  /** The executor was built with an `analytics` port; without it each `uses.analytics` key is blocked. */
88
90
  analyticsPort?: boolean;
91
+ /** The executor was built with a `reporting` port; without it each `uses.reporting` key is blocked. */
92
+ reportingPort?: boolean;
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
+ jobsProjectKeys?: boolean;
95
+ /**
96
+ * foundry#1122: ai offers a company Boule's five read permissions (`ai-boule-key.ts`). With it the organisation
97
+ * is offered them, the org's own ai key is minted again with them, and Boule's key is minted below it; without
98
+ * it Boule's key is blocked.
99
+ */
100
+ aiBouleReads?: boolean;
89
101
  /** C2: true when the executor was built with a `WorkerAppDatabasePort`; a `workerApp.database` is blocked without it. */
90
102
  workerAppDatabase?: boolean;
91
103
  /** True when that port has `deletableTables`; a `workerApp.database.noDelete` is blocked without it. */
@@ -286,6 +298,16 @@ export interface OrgPostgresPlan {
286
298
  export declare const AI_KEY_UNDELIVERED_SCOPE_KEY = "ai_key_undelivered";
287
299
  /** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
288
300
  export declare const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = "jobs_registration_undelivered";
301
+ /**
302
+ * Deduped `ai.*` permissions offered to every organisation by default
303
+ * (Decision, this campaign's report): the minimum set that lets an org
304
+ * actually run jobs against the platform's own offered models --
305
+ * `ai.offerings:invoke` to invoke an offering, `ai.runs:create` to submit the
306
+ * run itself. `ai/packages/service/src/catalogue.ts`'s five `offered: true`
307
+ * permissions also include the three `ai.connections:*` ones, which let an
308
+ * organisation bring its own provider credentials (BYOK) -- a separate,
309
+ * explicit opt-in this default deliberately leaves out.
310
+ */
289
311
  /**
290
312
  * #484: the spend cap foundry applies when an org declares no AI limit
291
313
  * (`org.ai.limitMicros === null`) -- Archon creates every company that way,