@wtfalch/foundry 0.76.0 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/dist/app-canary-worker.d.ts +35 -0
  2. package/dist/app-canary-worker.js +56 -0
  3. package/dist/audit/connector.d.ts +6 -2
  4. package/dist/audit/connector.js +3 -2
  5. package/dist/audit/evidence.d.ts +4 -0
  6. package/dist/audit/evidence.js +14 -2
  7. package/dist/audit/host-store.d.ts +58 -0
  8. package/dist/audit/host-store.js +147 -0
  9. package/dist/audit/host.d.ts +53 -0
  10. package/dist/audit/host.js +294 -0
  11. package/dist/audit/index.d.ts +2 -0
  12. package/dist/audit/index.js +2 -0
  13. package/dist/audit/registry.d.ts +14 -0
  14. package/dist/audit/sealer-role.d.ts +12 -0
  15. package/dist/audit/sealer-role.js +26 -2
  16. package/dist/audit/tick.d.ts +10 -2
  17. package/dist/audit/tick.js +15 -2
  18. package/dist/catalogue/copy-en.js +5 -0
  19. package/dist/catalogue/integrations.js +5 -0
  20. package/dist/entry-rules.d.ts +35 -0
  21. package/dist/entry-rules.js +35 -0
  22. package/dist/hosted-label.js +2 -1
  23. package/dist/index.d.ts +11 -3
  24. package/dist/index.js +6 -1
  25. package/dist/migrations/0072_audit_host.sql +46 -0
  26. package/dist/migrations/0074_platform_app_canary.sql +11 -0
  27. package/dist/modules/ai.js +15 -1
  28. package/dist/modules/assistants.js +3 -0
  29. package/dist/modules/email.d.ts +6 -1
  30. package/dist/modules/email.js +88 -5
  31. package/dist/modules/files.js +79 -2
  32. package/dist/modules/index.js +7 -0
  33. package/dist/modules/jobs.d.ts +5 -1
  34. package/dist/modules/jobs.js +74 -4
  35. package/dist/modules/reporting.d.ts +9 -0
  36. package/dist/modules/reporting.js +29 -0
  37. package/dist/modules/sign-in.js +20 -2
  38. package/dist/modules/types.d.ts +2 -0
  39. package/dist/modules/workspaces.js +3 -0
  40. package/dist/org.d.ts +114 -13
  41. package/dist/org.js +233 -20
  42. package/dist/plan/ai-boule-key-apply.d.ts +65 -0
  43. package/dist/plan/ai-boule-key-apply.js +169 -0
  44. package/dist/plan/ai-boule-key.d.ts +89 -0
  45. package/dist/plan/ai-boule-key.js +195 -0
  46. package/dist/plan/analytics.d.ts +8 -1
  47. package/dist/plan/analytics.js +8 -1
  48. package/dist/plan/app-canary-apply.d.ts +49 -0
  49. package/dist/plan/app-canary-apply.js +232 -0
  50. package/dist/plan/app-canary.d.ts +44 -0
  51. package/dist/plan/app-canary.js +110 -0
  52. package/dist/plan/blue-green-apply.d.ts +6 -2
  53. package/dist/plan/blue-green-apply.js +14 -3
  54. package/dist/plan/broker-entries.js +20 -5
  55. package/dist/plan/company-backups.js +2 -0
  56. package/dist/plan/desktop-auth.d.ts +17 -4
  57. package/dist/plan/desktop-auth.js +22 -5
  58. package/dist/plan/diff.d.ts +22 -0
  59. package/dist/plan/diff.js +231 -18
  60. package/dist/plan/entry-files-key.d.ts +39 -3
  61. package/dist/plan/entry-files-key.js +103 -6
  62. package/dist/plan/entry-jobs-boule-key-apply.d.ts +65 -0
  63. package/dist/plan/entry-jobs-boule-key-apply.js +192 -0
  64. package/dist/plan/entry-jobs-boule-key.d.ts +59 -0
  65. package/dist/plan/entry-jobs-boule-key.js +141 -0
  66. package/dist/plan/entry-jobs.d.ts +2 -0
  67. package/dist/plan/entry-reporting-key.d.ts +36 -0
  68. package/dist/plan/entry-reporting-key.js +136 -0
  69. package/dist/plan/executor.d.ts +108 -1
  70. package/dist/plan/executor.js +676 -24
  71. package/dist/plan/files-boule-key-apply.d.ts +73 -0
  72. package/dist/plan/files-boule-key-apply.js +223 -0
  73. package/dist/plan/files-boule-key.d.ts +92 -0
  74. package/dist/plan/files-boule-key.js +175 -0
  75. package/dist/plan/hostnames.js +4 -0
  76. package/dist/plan/index.d.ts +4 -0
  77. package/dist/plan/index.js +2 -0
  78. package/dist/plan/jobs-scheduler-http.d.ts +13 -0
  79. package/dist/plan/jobs-scheduler-http.js +39 -0
  80. package/dist/plan/live.d.ts +1 -1
  81. package/dist/plan/locked-hostnames.d.ts +1 -1
  82. package/dist/plan/locked-hostnames.js +4 -1
  83. package/dist/plan/login-host.js +2 -2
  84. package/dist/plan/org-names.js +3 -0
  85. package/dist/plan/own-diff.js +3 -2
  86. package/dist/plan/own-jobs-route.d.ts +5 -2
  87. package/dist/plan/own-jobs-route.js +6 -2
  88. package/dist/plan/own-limits.d.ts +6 -0
  89. package/dist/plan/own-limits.js +1 -0
  90. package/dist/plan/plan-apply.d.ts +24 -0
  91. package/dist/plan/plan-apply.js +39 -4
  92. package/dist/plan/platform-diff.d.ts +2 -0
  93. package/dist/plan/platform-diff.js +15 -1
  94. package/dist/plan/platform-providers.d.ts +9 -0
  95. package/dist/plan/platform-providers.js +21 -0
  96. package/dist/plan/providers-readers.js +25 -1
  97. package/dist/plan/registered-app-env.d.ts +31 -0
  98. package/dist/plan/registered-app-env.js +63 -0
  99. package/dist/plan/registered-app.d.ts +121 -0
  100. package/dist/plan/registered-app.js +262 -0
  101. package/dist/plan/release-host.js +2 -1
  102. package/dist/plan/reporting-boule.d.ts +18 -0
  103. package/dist/plan/reporting-boule.js +44 -0
  104. package/dist/plan/reporting.d.ts +90 -0
  105. package/dist/plan/reporting.js +135 -0
  106. package/dist/plan/stage-env.js +1 -0
  107. package/dist/plan/stage-isolation.d.ts +3 -1
  108. package/dist/plan/stage-isolation.js +20 -2
  109. package/dist/plan/stage-services.d.ts +3 -1
  110. package/dist/plan/stage-services.js +8 -1
  111. package/dist/plan/stage-sink.d.ts +2 -2
  112. package/dist/plan/stage-sink.js +2 -2
  113. package/dist/plan/workspace-client.d.ts +1 -1
  114. package/dist/plan/workspace-client.js +2 -1
  115. package/dist/plan/workspace-sign-in.d.ts +1 -1
  116. package/dist/plan/workspace-sign-in.js +39 -7
  117. package/dist/platform.d.ts +25 -0
  118. package/dist/platform.js +20 -0
  119. package/dist/service-labels.d.ts +8 -0
  120. package/dist/service-labels.js +8 -0
  121. package/dist/slug.d.ts +1 -1
  122. package/dist/slug.js +5 -2
  123. package/dist/store/postgres.js +13 -0
  124. package/dist/store/schema.d.ts +485 -2
  125. package/dist/store/schema.js +38 -1
  126. package/dist/worker-app-config.js +2 -2
  127. package/package.json +1 -1
@@ -0,0 +1,135 @@
1
+ import { appsOn } from '../org.js';
2
+ import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
3
+ import { bouleMailFrom } from './boule.js';
4
+ import { signinMailName } from './signin-mail.js';
5
+ /**
6
+ * A company's own reporting backend (https://github.com/wtfalch/reporting, ADR 0002, `docs/plans/backend.md`
7
+ * slice 5): one Coolify app and one database per company, built from that repo's root Dockerfile, the same shape
8
+ * as the analytics backend (`plan/analytics.ts`). It is planned only while something uses it: a hosting entry
9
+ * declares `uses.reporting`, or the company's Boule runs apps (`org.boule.apps`).
10
+ *
11
+ * What the host (`apps/host`) reads to start: `DATABASE_URL` and `DATABASE_URL_OWNER` (written by
12
+ * `pushDatabaseUrl`), `REPORTING_OPERATOR_KEY` (generated once here), `AUTH_ORGANIZATION_ID` (the company's
13
+ * sign-in organisation; the tenant id of every key and row derives from it, and without it minting answers 503)
14
+ * and `APP_URL`. It reads no other `AUTH_*` name yet, so no sign-in client is made for it. When its screens need
15
+ * `AUTH_ISSUER` and `AUTH_CLIENT_ID`, add the client then.
16
+ */
17
+ /** True while some entry declares `uses.reporting`, or Boule runs apps: the backend is wanted. */
18
+ export const reportingWanted = (org) => appsOn(org) || org.hosting.entries.some((e) => e.uses?.reporting === true);
19
+ /** `reporting.<domain>`, or `<slug>-reporting.oikos.host` for a company without a domain. */
20
+ export function reportingHostnameFor(org) {
21
+ return org.domain ? `reporting.${org.domain}` : `${org.slug}-reporting.oikos.host`;
22
+ }
23
+ /** The public address apps and Boule send to, `REPORTING_URL`. */
24
+ export const reportingUrlFor = (org) => `https://${reportingHostnameFor(org)}`;
25
+ /** The backend's database uuid, recorded beside the app's own id. */
26
+ export const REPORTING_DATABASE_SCOPE_KEY = 'coolify_reporting_database';
27
+ /** Recorded once the app was deployed, as `coolify_boule_deployed` is for Boule. */
28
+ export const REPORTING_DEPLOYED_SCOPE_KEY = 'coolify_reporting_deployed';
29
+ /** The env name the backend reads its operator key from; also Boule's name for it. */
30
+ export const REPORTING_OPERATOR_KEY_ENV = 'REPORTING_OPERATOR_KEY';
31
+ /** The env names `desiredReportingEnv` writes, so the live reader knows which to read back. */
32
+ export const REPORTING_MANAGED_ENV_KEYS = ['APP_URL', 'AUTH_ORGANIZATION_ID', 'MAIL_FROM'];
33
+ /**
34
+ * The Lettermint token the backend sends error alerts with (reporting#103): the company's `<slug>-app` token,
35
+ * joined as a consumer like a `uses.mail` entry. A secret: the executor writes it, the live reader says only
36
+ * whether it is there (`config.mailTokenHeld`), and no plan carries its value.
37
+ */
38
+ export const REPORTING_MAIL_TOKEN_ENV = 'LETTERMINT_API_TOKEN';
39
+ /**
40
+ * The backend's sender, `Name <address>`: the company's display name and the same no-reply address the main
41
+ * app and Boule send from. The name is one line, and quoted when it holds a character an address header
42
+ * gives a meaning to.
43
+ */
44
+ export function reportingMailFrom(org, mailDomain) {
45
+ const address = bouleMailFrom(org, mailDomain);
46
+ const name = signinMailName(org.displayName)
47
+ .replace(/["\\<>]/g, '')
48
+ .trim();
49
+ if (!name)
50
+ return address;
51
+ return /[()[\]:;@,.]/.test(name) ? `"${name}" <${address}>` : `${name} <${address}>`;
52
+ }
53
+ /**
54
+ * The `REPORTING_*` names foundry's reporting steps write: an entry's address and site key, and Boule's
55
+ * address and operator key. Every other `REPORTING_*` name on a blue/green color is the color's own
56
+ * (`REPORTING_COLOR`, `REPORTING_RELEASE`, `REPORTING_HEALTH_TOKEN`), so these are the ones a color copies from
57
+ * blue like any other env.
58
+ */
59
+ export const REPORTING_SHARED_ENV_NAMES = [
60
+ 'REPORTING_URL',
61
+ 'REPORTING_SITE_KEY',
62
+ REPORTING_OPERATOR_KEY_ENV,
63
+ ];
64
+ /** Whether a `REPORTING_*` name belongs to one blue/green color and is never copied or removed by the copy. */
65
+ export const isColorReportingEnv = (key) => key.startsWith('REPORTING_') && !REPORTING_SHARED_ENV_NAMES.includes(key);
66
+ /**
67
+ * What the backend's env must hold. `mailDomain` (the company's recorded mail domain, given only while its
68
+ * mail is on and not blocked) adds `MAIL_FROM`; the token is never here.
69
+ */
70
+ export function desiredReportingEnv(org, recorded, mailDomain = null) {
71
+ const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
72
+ const env = { APP_URL: reportingUrlFor(org) };
73
+ if (zitadelOrgId)
74
+ env.AUTH_ORGANIZATION_ID = zitadelOrgId;
75
+ if (mailDomain)
76
+ env.MAIL_FROM = reportingMailFrom(org, mailDomain);
77
+ return { env, missing: zitadelOrgId ? [] : ['zitadelOrgId'] };
78
+ }
79
+ export function reportingEnvDrift(desired, live) {
80
+ return Object.entries(desired)
81
+ .filter(([key, value]) => live[key] !== value)
82
+ .map(([key]) => key);
83
+ }
84
+ const CALL_TIMEOUT_MS = 20_000;
85
+ /**
86
+ * The real {@link ReportingPort}: `POST <baseUrl>/v1/site-keys` with `{ name, site, environment }` answers 201
87
+ * `{ id, secret, site, environment }`; `DELETE <baseUrl>/v1/site-keys/<id>` answers 204. A 201 that binds the
88
+ * key to another site or environment than asked is refused, so a key never reports where Boule does not read.
89
+ * Errors carry the status only, never a body, the key or the secret.
90
+ */
91
+ export function httpReporting(options = {}) {
92
+ const fetchImpl = options.fetch ?? fetch;
93
+ const call = async (url, init, what) => {
94
+ try {
95
+ return await fetchImpl(url, {
96
+ ...init,
97
+ redirect: 'error',
98
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
99
+ });
100
+ }
101
+ catch {
102
+ throw new Error(`the reporting backend did not answer when asked to ${what}`);
103
+ }
104
+ };
105
+ return {
106
+ async mintSiteKey({ baseUrl, operatorKey, name, site, environment }) {
107
+ const response = await call(`${baseUrl}/v1/site-keys`, {
108
+ method: 'POST',
109
+ headers: { authorization: `Bearer ${operatorKey}`, 'content-type': 'application/json' },
110
+ body: JSON.stringify({ name, site, environment }),
111
+ }, 'mint a site key');
112
+ if (response.status !== 201) {
113
+ throw Object.assign(new Error(`the reporting backend refused to mint a site key (status ${response.status})`), { status: response.status });
114
+ }
115
+ const body = await response.json().catch(() => undefined);
116
+ const fields = typeof body === 'object' && body !== null ? body : {};
117
+ if (typeof fields.id !== 'string' ||
118
+ !fields.id ||
119
+ typeof fields.secret !== 'string' ||
120
+ !fields.secret) {
121
+ throw new Error('the reporting backend answered 201 but sent no key id and secret');
122
+ }
123
+ if (fields.site !== site || fields.environment !== environment) {
124
+ throw new Error(`the reporting backend answered 201 with key ${fields.id} bound to another site or environment than asked`);
125
+ }
126
+ return { id: fields.id, secret: fields.secret };
127
+ },
128
+ async revokeSiteKey({ baseUrl, operatorKey, keyId }) {
129
+ const response = await call(`${baseUrl}/v1/site-keys/${encodeURIComponent(keyId)}`, { method: 'DELETE', headers: { authorization: `Bearer ${operatorKey}` } }, 'revoke a site key');
130
+ if (response.status !== 204) {
131
+ throw Object.assign(new Error(`the reporting backend refused to revoke a site key (status ${response.status})`), { status: response.status });
132
+ }
133
+ },
134
+ };
135
+ }
@@ -5,6 +5,7 @@ const WAITS_FOR_STAGE_SERVICE = new Set([
5
5
  'AI_URL',
6
6
  'JOBS_SCHEDULER_URL',
7
7
  'ANALYTICS_URL',
8
+ 'REPORTING_URL',
8
9
  'WORKSPACES_URL',
9
10
  'MCP_ISSUER',
10
11
  ]);
@@ -42,6 +42,8 @@ export interface LiveSignIn {
42
42
  customersOrgId?: string;
43
43
  /** The live app's client ids. */
44
44
  clientIds: readonly string[];
45
+ /** The team clients of the live apps whose customers and team both sign in (ADR 0003). */
46
+ teamClientIds?: readonly string[];
45
47
  /** The live workspaces keys. */
46
48
  workspacesKeys: readonly string[];
47
49
  /** The live apps' MCP audiences (the ZITADEL project ids). */
@@ -72,7 +74,7 @@ export interface StageIsolationInput {
72
74
  * {@link SHARED_WITH_PROD} differs from production's), `live_mode_key`.
73
75
  * - A4 names: `resource_shared`, `host_is_prod_internal`.
74
76
  * - A5 mail: `mail_not_sink`, `mail_token`.
75
- * - A6 sign-in (with `signIn`): `live_customers_org`, `live_client_id`, `live_workspaces_key`,
77
+ * - A6 sign-in (with `signIn`): `live_customers_org`, `live_client_id`, `live_team_client_id`, `live_workspaces_key`,
76
78
  * `live_mcp_audience`, `live_mcp_resource` when stage holds the live customers organisation id, a live client
77
79
  * id, the live workspaces key, or a live MCP audience or resource.
78
80
  */
@@ -17,7 +17,11 @@ function globMatches(pattern, name) {
17
17
  return pattern.endsWith('*') ? name.startsWith(pattern.slice(0, -1)) : pattern === name;
18
18
  }
19
19
  const matchesAny = (patterns, name) => patterns.some((p) => globMatches(p, name));
20
- const MAIL_NAME = /^(MAIL_|LETTERMINT_|SMTP_)/;
20
+ /**
21
+ * The mail names A5 holds to the sink's wiring. `MAIL_APP_KEY` is left out: it only tags a send with the app's
22
+ * key (`<key>-work` on stage, never production's), so it is checked as any other allowed name.
23
+ */
24
+ const MAIL_NAME = /^(MAIL_(?!APP_KEY$)|LETTERMINT_|SMTP_)/;
21
25
  /** Env names (or `PREFIX_*`) each kind of stage app may hold. A name not here is refused. */
22
26
  export const STAGE_ALLOWLIST = {
23
27
  boule: [
@@ -75,6 +79,8 @@ export const STAGE_ALLOWLIST = {
75
79
  'AUTH_CLIENT_ID',
76
80
  'AUTH_APP_KEY',
77
81
  'AUTH_COOKIE_SECRET',
82
+ 'AUTH_TEAM_CLIENT_ID',
83
+ 'AUTH_TEAM_ORGANIZATION_ID',
78
84
  'FILES_URL',
79
85
  'FILES_ORGANISATION_ID',
80
86
  'FILES_API_KEY',
@@ -87,12 +93,15 @@ export const STAGE_ALLOWLIST = {
87
93
  'JOBS_SYNC_KEY',
88
94
  'ANALYTICS_URL',
89
95
  'ANALYTICS_SITE_KEY',
96
+ 'REPORTING_URL',
97
+ 'REPORTING_SITE_KEY',
90
98
  'WORKSPACES_URL',
91
99
  'WORKSPACES_API_KEY',
92
100
  'AUTH_EVENTS_SECRET',
93
101
  'MCP_AUDIENCE',
94
102
  'MCP_RESOURCE',
95
103
  'MCP_ISSUER',
104
+ 'MAIL_APP_KEY',
96
105
  ],
97
106
  };
98
107
  /** Names no stage holds, whatever the app. */
@@ -121,6 +130,9 @@ const DENY_COMMON = [
121
130
  'ARCHON_SETUP_CODE',
122
131
  'ARCHON_FIRST_OWNER_LINK',
123
132
  'MAINTENANCE_BYPASS_TOKEN',
133
+ // foundry#1137: the analytics backend's person key lives in that backend's own env only. No stage or
134
+ // workspace copy of the backend exists; a stage that ever holds the name is refused, whatever its value.
135
+ 'ANALYTICS_PERSON_KEY',
124
136
  ];
125
137
  const DENY_BY_KIND = {
126
138
  boule: ['JOBS_*', 'EDGE_*'],
@@ -155,6 +167,7 @@ export function isAllowed(kind, name) {
155
167
  export const SHARED_WITH_PROD = {
156
168
  AUTH_ISSUER: 'stage signs in through the same issuer by decision; it holds no ZITADEL management token, and an OIDC client cannot write users',
157
169
  AUTH_ORGANIZATION_ID: 'same issuer organisation as AUTH_ISSUER; an id, not a credential',
170
+ AUTH_TEAM_ORGANIZATION_ID: "the company's own organisation, where the team signs in on stage too, with a client of stage's own; an id, not a credential",
158
171
  CI_ORGANIZATION_ID: 'an id of the same issuer organisation; not a credential',
159
172
  CUSTOMERS_ORGANISATION_ID: 'an id of the customers organisation in the same issuer; not a credential',
160
173
  BOULE_ORG_NAME: 'a display name',
@@ -165,6 +178,7 @@ export const SHARED_WITH_PROD = {
165
178
  AI_URL: 'the ai service is shared; stage reaches it only with its own organisation and key',
166
179
  JOBS_SCHEDULER_URL: 'the jobs service is shared; stage reaches it only with its own project and signing secret',
167
180
  ANALYTICS_URL: 'the analytics collector is shared; stage reports under its own site key',
181
+ REPORTING_URL: "the company's reporting backend is shared; stage sends under its own site key, environment stage",
168
182
  MCP_ISSUER: 'the sign-in service is shared by decision; the issuer is a public address, not a credential',
169
183
  WORKSPACES_URL: "the broker is shared; stage's own workspaces key opens its own product tenant, which holds no live customers' workspaces",
170
184
  };
@@ -233,7 +247,7 @@ function agoraConfigViolations(value, prodByName, prodHosts) {
233
247
  * {@link SHARED_WITH_PROD} differs from production's), `live_mode_key`.
234
248
  * - A4 names: `resource_shared`, `host_is_prod_internal`.
235
249
  * - A5 mail: `mail_not_sink`, `mail_token`.
236
- * - A6 sign-in (with `signIn`): `live_customers_org`, `live_client_id`, `live_workspaces_key`,
250
+ * - A6 sign-in (with `signIn`): `live_customers_org`, `live_client_id`, `live_team_client_id`, `live_workspaces_key`,
237
251
  * `live_mcp_audience`, `live_mcp_resource` when stage holds the live customers organisation id, a live client
238
252
  * id, the live workspaces key, or a live MCP audience or resource.
239
253
  */
@@ -281,6 +295,10 @@ export function assertStageIsolated(input) {
281
295
  if (name === 'AUTH_CLIENT_ID' && live.clientIds.includes(item.value)) {
282
296
  out.add(`live_client_id:${name}`);
283
297
  }
298
+ if (name === 'AUTH_TEAM_CLIENT_ID' &&
299
+ (live.teamClientIds?.includes(item.value) || live.clientIds.includes(item.value))) {
300
+ out.add(`live_team_client_id:${name}`);
301
+ }
284
302
  if (name === 'WORKSPACES_API_KEY' && live.workspacesKeys.includes(item.value)) {
285
303
  out.add(`live_workspaces_key:${name}`);
286
304
  }
@@ -5,7 +5,7 @@ import { type StageKind } from './stage-isolation.js';
5
5
  * minted for it only. A stage key is worthless on production, and production's key never reaches stage. Nothing
6
6
  * here returns, logs or throws a secret: errors carry fixed text, ids and refs (CLAUDE.md, Platform steps).
7
7
  */
8
- export type StageService = 'files' | 'ai' | 'jobs' | 'analytics';
8
+ export type StageService = 'files' | 'ai' | 'jobs' | 'analytics' | 'reporting';
9
9
  export declare const STAGE_SERVICES: readonly StageService[];
10
10
  /** One credential a service shows once: the env name stage holds it under, and its value. */
11
11
  export interface StageSecret {
@@ -46,6 +46,8 @@ export interface StageServicesPort {
46
46
  jobs?: StageServicePort;
47
47
  /** The company's analytics backend: stage's own site key (environment `stage`), shown once with the mint. */
48
48
  analytics?: StageServicePort;
49
+ /** The company's reporting backend: stage's own site key (site = the entry key, environment `stage`), shown once with the mint. */
50
+ reporting?: StageServicePort;
49
51
  /** The id of production's jobs project, so stage's jobs key can be tried against it. */
50
52
  prodJobsProjectId?: string;
51
53
  }
@@ -1,7 +1,13 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { bgKey } from './blue-green.js';
3
3
  import { isAllowed } from './stage-isolation.js';
4
- export const STAGE_SERVICES = ['files', 'ai', 'jobs', 'analytics'];
4
+ export const STAGE_SERVICES = [
5
+ 'files',
6
+ 'ai',
7
+ 'jobs',
8
+ 'analytics',
9
+ 'reporting',
10
+ ];
5
11
  /** Fixed text and ids only: safe to log, record and return. */
6
12
  export class StageServiceError extends Error {
7
13
  name = 'StageServiceError';
@@ -15,6 +21,7 @@ const SERVICE_ENV = {
15
21
  url: 'JOBS_SCHEDULER_URL',
16
22
  },
17
23
  analytics: { evidence: ['ANALYTICS_URL', 'ANALYTICS_SITE_KEY'], url: 'ANALYTICS_URL' },
24
+ reporting: { evidence: ['REPORTING_URL', 'REPORTING_SITE_KEY'], url: 'REPORTING_URL' },
18
25
  };
19
26
  /** The fixed reasons a used service is recorded absent. */
20
27
  export const STAGE_SERVICE_ABSENT = {
@@ -19,9 +19,9 @@ export declare const STAGE_SINK_APPLIED_SCOPE_KEY = "stage_sink_applied";
19
19
  export declare const STAGE_SINK_APP_SCOPE_KEY = "stage_sink_app";
20
20
  /**
21
21
  * Part of the structure hash of a primary server. Raise it when the sink wiring changes, so an applied
22
- * structure runs once more.
22
+ * structure runs once more. 2: a stage of an entry that sends mail also gets `MAIL_APP_KEY` (`<key>-work`).
23
23
  */
24
- export declare const STAGE_SINK_VERSION = 1;
24
+ export declare const STAGE_SINK_VERSION = 2;
25
25
  type SinkOrg = Pick<DeclaredOrg, 'slug'>;
26
26
  export declare const stageSinkAppName: (org: SinkOrg) => string;
27
27
  /** The sink's HTTP address on the company's Docker network. */
@@ -18,9 +18,9 @@ export const STAGE_SINK_APPLIED_SCOPE_KEY = 'stage_sink_applied';
18
18
  export const STAGE_SINK_APP_SCOPE_KEY = 'stage_sink_app';
19
19
  /**
20
20
  * Part of the structure hash of a primary server. Raise it when the sink wiring changes, so an applied
21
- * structure runs once more.
21
+ * structure runs once more. 2: a stage of an entry that sends mail also gets `MAIL_APP_KEY` (`<key>-work`).
22
22
  */
23
- export const STAGE_SINK_VERSION = 1;
23
+ export const STAGE_SINK_VERSION = 2;
24
24
  export const stageSinkAppName = (org) => `${org.slug}-stage-sink`;
25
25
  /** The sink's HTTP address on the company's Docker network. */
26
26
  export const stageSinkInternalUrl = (org) => `http://${stageSinkAppName(org)}:${STAGE_SINK_HTTP_PORT}`;
@@ -1,4 +1,4 @@
1
- import type { DeclaredOrg, HostingEntry } from '../org.js';
1
+ import { type DeclaredOrg, type HostingEntry } from '../org.js';
2
2
  /** The name of an entry's workspace client and of the key its workspace broker entry is held under. */
3
3
  export declare const workspaceClientKey: (entryKey: string) => string;
4
4
  /** The company's ZITADEL organisation for the customers of its workspaces. The space keeps it apart from every slug. */
@@ -1,3 +1,4 @@
1
+ import { signsInCustomers } from '../org.js';
1
2
  import { workspaceHost } from './hostnames.js';
2
3
  /**
3
4
  * What the sign-in of a server workspace is called, shared by the planners that must agree on it. A workspace of
@@ -27,7 +28,7 @@ export function workspaceAuthEntries(org) {
27
28
  /** The entry whose workspace client is named `clientKey`, or `undefined`. */
28
29
  export const workspaceAuthEntryOf = (org, clientKey) => workspaceAuthEntries(org).find((entry) => workspaceClientKey(entry.key) === clientKey);
29
30
  /** The entries whose workspace signs in the test customers organisation. */
30
- export const testCustomersEntries = (org) => workspaceAuthEntries(org).filter((entry) => entry.uses?.auth?.users === 'customers');
31
+ export const testCustomersEntries = (org) => workspaceAuthEntries(org).filter((entry) => signsInCustomers(entry.uses?.auth));
31
32
  /** The addresses a workspace client signs in at: the workspace host. */
32
33
  export const workspaceClientHosts = (org, entry) => [
33
34
  workspaceHost(org.slug, entry.key),
@@ -1,4 +1,4 @@
1
- import type { DeclaredOrg, HostingEntry } from '../org.js';
1
+ import { type DeclaredOrg, type HostingEntry } from '../org.js';
2
2
  import type { UnmanagedItem } from '../plan.js';
3
3
  import { type BrokerEntryInput, type BrokerEntryMcp, type BrokerEntryWant } from './broker-entries.js';
4
4
  import type { DraftStep } from './diff.js';
@@ -1,3 +1,4 @@
1
+ import { signsInBoth, signsInCustomers } from '../org.js';
1
2
  import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, entryClientIdScopeKey, } from './agora.js';
2
3
  import { BOULE_CLIENT_ID_SCOPE_KEY } from './boule.js';
3
4
  import { brokerEntryAppliedScopeKey, entryMcpProjectScopeKey, } from './broker-entries.js';
@@ -5,6 +6,7 @@ import { BROKER_WORKSPACES_DATABASE_ENV, workspacesDatabaseRef, workspacesUrl, }
5
6
  import { workspaceHost } from './hostnames.js';
6
7
  import { loginHostnameFor } from './login-host.js';
7
8
  import { appStepKey } from './platform-diff.js';
9
+ import { liveRegisteredClientIds, registeredAppEnv } from './registered-app-env.js';
8
10
  import { TEST_CUSTOMERS_SIGN_IN_SCOPE_KEY, TEST_CUSTOMERS_SIGN_UP_SCOPE_KEY, testCustomersEntries, testCustomersOrgKey, workspaceAuthEntries, workspaceClientKey, } from './workspace-client.js';
9
11
  /**
10
12
  * The sign-in of a server workspace (`HostingEntry.workspace` with `uses.auth`), so the team and invited testers
@@ -49,7 +51,27 @@ export function workspaceAuthEnv(org, entry, recorded, issuer) {
49
51
  const auth = entry.uses?.auth;
50
52
  if (!auth)
51
53
  return null;
52
- const organizationId = auth.users === 'customers' ? recorded[testCustomersOrgKey(org.id)] : recorded.zitadel_org;
54
+ const organizationId = signsInCustomers(auth)
55
+ ? recorded[testCustomersOrgKey(org.id)]
56
+ : recorded.zitadel_org;
57
+ if (signsInBoth(auth)) {
58
+ // ADR 0003: the broker makes the workspace's two clients when it is registered, after the stage exists, so the
59
+ // stage first runs with the organisation and the issuer alone; the registration step adds the rest.
60
+ if (!organizationId || !issuer)
61
+ return null;
62
+ const registered = registeredAppEnv(recorded, workspaceClientKey(entry.key));
63
+ return {
64
+ AUTH_ORGANIZATION_ID: organizationId,
65
+ ...(registered ? { AUTH_CLIENT_ID: registered.AUTH_CLIENT_ID } : {}),
66
+ AUTH_ISSUER: issuer,
67
+ ...(registered
68
+ ? {
69
+ AUTH_TEAM_CLIENT_ID: registered.AUTH_TEAM_CLIENT_ID,
70
+ AUTH_TEAM_ORGANIZATION_ID: registered.AUTH_TEAM_ORGANIZATION_ID,
71
+ }
72
+ : {}),
73
+ };
74
+ }
53
75
  const clientId = recorded[entryClientIdScopeKey(workspaceClientKey(entry.key))];
54
76
  if (!organizationId || !clientId || !issuer)
55
77
  return null;
@@ -82,12 +104,16 @@ const SHARED_CLIENT_KEYS = new Set([
82
104
  */
83
105
  export function liveClientIds(org, recorded) {
84
106
  const own = new Set(workspaceAuthEntries(org).map((e) => entryClientIdScopeKey(workspaceClientKey(e.key))));
85
- return Object.entries(recorded)
107
+ const recordedIds = Object.entries(recorded)
86
108
  .filter(([key, id]) => Boolean(id) &&
87
109
  (SHARED_CLIENT_KEYS.has(key) ||
88
110
  (key.startsWith(ENTRY_CLIENT_ID_SCOPE_PREFIX) && !own.has(key))))
89
- .map(([, id]) => id)
90
- .sort();
111
+ .map(([, id]) => id);
112
+ // The clients the broker made for the live copies of the entries both sign in to (ADR 0003).
113
+ const registered = liveRegisteredClientIds(org, recorded);
114
+ return [
115
+ ...new Set([...recordedIds, ...registered.clientIds, ...registered.teamClientIds]),
116
+ ].sort();
91
117
  }
92
118
  /** What `ws.<key>.signin` records: the sign-in env above as JSON, or `null` while it is not all known. */
93
119
  export function workspaceSignInMarker(org, entry, recorded, issuer) {
@@ -154,6 +180,9 @@ export function planTestCustomers(org, live, claimed, recordedIds, metadataWired
154
180
  export function planWorkspaceClients(org, byKey, claimed) {
155
181
  const steps = [];
156
182
  for (const entry of workspaceAuthEntries(org)) {
183
+ // The broker makes the clients of a workspace both sign in to (ADR 0003).
184
+ if (signsInBoth(entry.uses?.auth))
185
+ continue;
157
186
  const client = workspaceClientKey(entry.key);
158
187
  const key = resourceKey('zitadel', 'app', client);
159
188
  if (byKey.has(key)) {
@@ -177,12 +206,14 @@ export function wantedWorkspaceBrokerEntry(org, entry, recorded) {
177
206
  if (!auth)
178
207
  return { missing: [] };
179
208
  const client = workspaceClientKey(entry.key);
180
- const organizationId = auth.users === 'customers' ? recorded[testCustomersOrgKey(org.id)] : recorded.zitadel_org;
209
+ const organizationId = signsInCustomers(auth)
210
+ ? recorded[testCustomersOrgKey(org.id)]
211
+ : recorded.zitadel_org;
181
212
  const clientId = recorded[entryClientIdScopeKey(client)];
182
213
  const mailDomain = recorded.mail_domain;
183
214
  const missing = [];
184
215
  if (!organizationId) {
185
- missing.push(auth.users === 'customers'
216
+ missing.push(signsInCustomers(auth)
186
217
  ? { what: 'the test customers organisation id', producedBy: ['zitadel', 'customers-test'] }
187
218
  : { what: "the org's ZITADEL org id", producedBy: ['zitadel', 'issuer'] });
188
219
  }
@@ -228,7 +259,8 @@ export function planWorkspaceBrokerEntries(org, recordedIds, plannedSteps, broke
228
259
  const blocked = [];
229
260
  for (const entry of workspaceAuthEntries(org)) {
230
261
  const auth = entry.uses?.auth;
231
- if (!auth)
262
+ // A workspace both sign in to is registered (`registered-app.ts`), not an `AUTH_APPS` entry.
263
+ if (!auth || signsInBoth(auth))
232
264
  continue;
233
265
  const at = `${org.slug}'s ${entry.key} workspace broker entry`;
234
266
  const key = `coolify:workspace-broker-entry:${entry.key}`;
@@ -87,6 +87,29 @@ export interface PlatformUsageTailWorker {
87
87
  }
88
88
  /** Where edge publishes the Worker script for `tag` (the host's `fetchArtifact` fetches it). */
89
89
  export declare function usageTailArtifactUrl(tag: string): string;
90
+ /**
91
+ * foundry#1133: the app-layer canary, a tiny Worker app on the layer every
92
+ * company's managed app runs on (`canary.companyincluded.app`, edge's Worker
93
+ * routes in the platform zone). Archon's uptime Worker probes `GET /` on the
94
+ * hostname foundry records (`platform_app_canary_hostname`, archon#812). The
95
+ * source ships in this package (`app-canary-worker.ts`); the same
96
+ * `tag`/`sha256` pin {@link PlatformUsageTailWorker} uses, and both empty
97
+ * means nothing is pinned yet, so the planner reports that instead of
98
+ * planning the step. Absent is off.
99
+ */
100
+ export interface PlatformAppCanary {
101
+ enabled: boolean;
102
+ /** A tag such as `appCanaryShippedPin().tag`, `v0-<12 hex>`. */
103
+ tag: string;
104
+ /** Lower-case hex SHA-256 of `app-canary-worker.js` (`APP_CANARY_WORKER_SHA256`). */
105
+ sha256: string;
106
+ }
107
+ /**
108
+ * The URL the canary's pin names at the `fetchArtifact(url, sha256)` boundary.
109
+ * Nothing is published there: a host serves the bytes from the package with
110
+ * `fetchAppCanaryArtifact`, the way Archon serves its uptime Worker from its image.
111
+ */
112
+ export declare function appCanaryArtifactUrl(tag: string): string;
90
113
  /**
91
114
  * The shared services a declared platform names, decided in #165, plus
92
115
  * `edge` (#317, F7): the service that mints an entry's own edge key and
@@ -160,6 +183,8 @@ export interface DeclaredPlatform {
160
183
  uptime: PlatformUptimeWorker;
161
184
  /** foundry#1024: the usage tail Worker; see {@link PlatformUsageTailWorker}. */
162
185
  usageTail: PlatformUsageTailWorker;
186
+ /** foundry#1133: the app-layer canary; see {@link PlatformAppCanary}. Absent is off. */
187
+ appCanary?: PlatformAppCanary;
163
188
  /**
164
189
  * foundry#544: which Healthchecks instance manages the heartbeat checks
165
190
  * for every piece of scheduled work foundry sets up (the uptime Worker,
package/dist/platform.js CHANGED
@@ -8,6 +8,14 @@ export function uptimeArtifactUrl(tag) {
8
8
  export function usageTailArtifactUrl(tag) {
9
9
  return `https://github.com/wtfalch/edge/releases/download/${tag}/usage-tail-worker.js`;
10
10
  }
11
+ /**
12
+ * The URL the canary's pin names at the `fetchArtifact(url, sha256)` boundary.
13
+ * Nothing is published there: a host serves the bytes from the package with
14
+ * `fetchAppCanaryArtifact`, the way Archon serves its uptime Worker from its image.
15
+ */
16
+ export function appCanaryArtifactUrl(tag) {
17
+ return `https://github.com/wtfalch/foundry/releases/download/${tag}/app-canary-worker.js`;
18
+ }
11
19
  export const PLATFORM_SERVICE_KEYS = [
12
20
  'auth',
13
21
  'mail',
@@ -101,6 +109,18 @@ export function validatePlatform(platform) {
101
109
  if (usageTail.sha256 && !/^[0-9a-f]{64}$/.test(usageTail.sha256)) {
102
110
  return 'usageTail.sha256 must be 64 lower-case hex characters';
103
111
  }
112
+ if (platform.appCanary) {
113
+ const { appCanary } = platform;
114
+ if (Boolean(appCanary.tag.trim()) !== Boolean(appCanary.sha256.trim())) {
115
+ return 'appCanary.tag and appCanary.sha256 must be set together: a pin is both';
116
+ }
117
+ if (appCanary.tag && !/^v[0-9A-Za-z][0-9A-Za-z.+_-]*$/.test(appCanary.tag)) {
118
+ return `appCanary.tag '${appCanary.tag}' must be a release tag such as v1.2.3`;
119
+ }
120
+ if (appCanary.sha256 && !/^[0-9a-f]{64}$/.test(appCanary.sha256)) {
121
+ return 'appCanary.sha256 must be 64 lower-case hex characters';
122
+ }
123
+ }
104
124
  // #491: declared, never discovered -- a present-but-blank id is refused the
105
125
  // same way `serverIpv4`'s blank string is, rather than being read as unset.
106
126
  if (platform.mail.lettermintProjectId != null && !platform.mail.lettermintProjectId.trim()) {
@@ -2,3 +2,11 @@
2
2
  export declare const OWN_SERVICE_LABELS: readonly ["auth", "mail", "files", "sfu"];
3
3
  /** The first label of every name foundry itself serves for a company: its own services, Agora, Boule, the cms studio, calls, the sign-in host and file shares. */
4
4
  export declare const FOUNDRY_SERVICE_LABELS: ReadonlySet<string>;
5
+ /**
6
+ * foundry#1133: first labels the platform serves on the apps zone
7
+ * (`<label>.companyincluded.app`) itself, so no company may take one as an app
8
+ * address: the app-layer canary Archon's uptime probes (`canary`) and its
9
+ * Worker's name (`uptime-canary`). Kept equal to `PLATFORM_APP_CANARY_LABEL`
10
+ * and `PLATFORM_APP_CANARY_SCRIPT_NAME` by `app-canary.test.ts`.
11
+ */
12
+ export declare const PLATFORM_APP_LABELS: ReadonlySet<string>;
@@ -9,3 +9,11 @@ export const FOUNDRY_SERVICE_LABELS = new Set([
9
9
  'calls',
10
10
  'share',
11
11
  ]);
12
+ /**
13
+ * foundry#1133: first labels the platform serves on the apps zone
14
+ * (`<label>.companyincluded.app`) itself, so no company may take one as an app
15
+ * address: the app-layer canary Archon's uptime probes (`canary`) and its
16
+ * Worker's name (`uptime-canary`). Kept equal to `PLATFORM_APP_CANARY_LABEL`
17
+ * and `PLATFORM_APP_CANARY_SCRIPT_NAME` by `app-canary.test.ts`.
18
+ */
19
+ export const PLATFORM_APP_LABELS = new Set(['canary', 'uptime-canary']);
package/dist/slug.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  export declare const RESERVED_SLUGS: ReadonlySet<string>;
3
3
  /** Every platform app, tunnel, project and database name starts `platform-`, so no company slug may. */
4
4
  export declare const RESERVED_SLUG_PREFIX = "platform-";
5
- /** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-kosmos`, `-analytics`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
5
+ /** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-kosmos`, `-analytics`, `-reporting`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
6
6
  export declare function hasBarredSlugSuffix(label: string): boolean;
7
7
  /**
8
8
  * Why {@link validateSlug} refused a candidate slug, by name so a caller —
package/dist/slug.js CHANGED
@@ -148,6 +148,8 @@ const BOULE_SUFFIX = /-boule$/;
148
148
  const KOSMOS_SUFFIX = /-kosmos$/;
149
149
  /** `<something>-analytics` — a level-1 org's own analytics backend hostname (`<slug>-analytics.oikos.host`, foundry#724). Reserved always, so a company that turns analytics on later never finds the name taken. */
150
150
  const ANALYTICS_SUFFIX = /-analytics$/;
151
+ /** `<something>-reporting` — a level-1 org's own reporting backend hostname (`<slug>-reporting.oikos.host`, wtfalch/reporting ADR 0002). Reserved always, as {@link ANALYTICS_SUFFIX} is. */
152
+ const REPORTING_SUFFIX = /-reporting$/;
151
153
  /** `<something>-auth|-mail|-files|-sfu|-cms` — an own company's service hostnames on oikos.host (`plan/own-hostnames.ts`). */
152
154
  const OWN_SERVICE_SUFFIX = /-(auth|mail|files|sfu|cms)$/;
153
155
  /**
@@ -161,7 +163,7 @@ const OWN_SERVICE_SUFFIX = /-(auth|mail|files|sfu|cms)$/;
161
163
  * `names-`.
162
164
  */
163
165
  const NAMES_PREFIX = /^names(-|$)/;
164
- /** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-kosmos`, `-analytics`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
166
+ /** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-kosmos`, `-analytics`, `-reporting`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
165
167
  export function hasBarredSlugSuffix(label) {
166
168
  return (STAGE_SUFFIX.test(label) ||
167
169
  PR_SUFFIX.test(label) ||
@@ -170,6 +172,7 @@ export function hasBarredSlugSuffix(label) {
170
172
  BOULE_SUFFIX.test(label) ||
171
173
  KOSMOS_SUFFIX.test(label) ||
172
174
  ANALYTICS_SUFFIX.test(label) ||
175
+ REPORTING_SUFFIX.test(label) ||
173
176
  OWN_SERVICE_SUFFIX.test(label));
174
177
  }
175
178
  /**
@@ -201,7 +204,7 @@ export function validateSlug(slug) {
201
204
  if (hasBarredSlugSuffix(slug)) {
202
205
  return {
203
206
  reason: 'environment-suffix',
204
- message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora', '-boule', '-kosmos', '-analytics', '-auth', '-mail', '-files', '-sfu' or '-cms') reserved for another org's own hostname (#28, #145, #164, #161, #949) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, 'acme-boule' only as acme's Boule hostname, 'acme-kosmos' only as acme's kosmos hostname, never as an org slug in its own right`,
207
+ message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora', '-boule', '-kosmos', '-analytics', '-reporting', '-auth', '-mail', '-files', '-sfu' or '-cms') reserved for another org's own hostname (#28, #145, #164, #161, #949) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, 'acme-boule' only as acme's Boule hostname, 'acme-kosmos' only as acme's kosmos hostname, never as an org slug in its own right`,
205
208
  };
206
209
  }
207
210
  return null;
@@ -820,6 +820,16 @@ function rowToPlatform(row) {
820
820
  tag: row.usageTailTag,
821
821
  sha256: row.usageTailSha256,
822
822
  },
823
+ // Absent while off and unpinned, the way an older platform was declared.
824
+ ...(row.appCanaryEnabled || row.appCanaryTag || row.appCanarySha256
825
+ ? {
826
+ appCanary: {
827
+ enabled: row.appCanaryEnabled,
828
+ tag: row.appCanaryTag,
829
+ sha256: row.appCanarySha256,
830
+ },
831
+ }
832
+ : {}),
823
833
  heartbeatsProvider: row.heartbeatsProvider,
824
834
  };
825
835
  }
@@ -875,6 +885,9 @@ function platformToRow(platform) {
875
885
  usageTailEnabled: platform.usageTail.enabled,
876
886
  usageTailTag: platform.usageTail.tag,
877
887
  usageTailSha256: platform.usageTail.sha256,
888
+ appCanaryEnabled: platform.appCanary?.enabled ?? false,
889
+ appCanaryTag: platform.appCanary?.tag ?? '',
890
+ appCanarySha256: platform.appCanary?.sha256 ?? '',
878
891
  heartbeatsProvider: platform.heartbeatsProvider,
879
892
  };
880
893
  }