@wtfalch/foundry 0.37.0 → 0.39.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 (39) hide show
  1. package/dist/bootstrap/versions.d.ts +3 -2
  2. package/dist/index.d.ts +1 -1
  3. package/dist/plan/backup-gate.d.ts +19 -3
  4. package/dist/plan/backup-gate.js +49 -4
  5. package/dist/plan/company-backups.d.ts +7 -2
  6. package/dist/plan/company-backups.js +25 -6
  7. package/dist/plan/diff.d.ts +4 -0
  8. package/dist/plan/diff.js +19 -1
  9. package/dist/plan/executor.d.ts +13 -0
  10. package/dist/plan/executor.js +56 -13
  11. package/dist/plan/index.d.ts +2 -1
  12. package/dist/plan/index.js +1 -0
  13. package/dist/plan/mail-store.d.ts +34 -0
  14. package/dist/plan/mail-store.js +49 -0
  15. package/dist/plan/own-diff.d.ts +26 -0
  16. package/dist/plan/own-diff.js +74 -6
  17. package/dist/plan/own-services.d.ts +11 -0
  18. package/dist/plan/own-services.js +12 -0
  19. package/dist/plan/plan-apply.d.ts +4 -0
  20. package/dist/plan/plan-apply.js +30 -4
  21. package/dist/plan/platform-diff.d.ts +30 -0
  22. package/dist/plan/platform-diff.js +62 -7
  23. package/dist/plan/platform-org.d.ts +15 -0
  24. package/dist/plan/platform-org.js +17 -1
  25. package/dist/plan/platform-providers.d.ts +69 -2
  26. package/dist/plan/platform-providers.js +255 -41
  27. package/dist/plan/signin-db-backup.d.ts +105 -0
  28. package/dist/plan/signin-db-backup.js +193 -0
  29. package/dist/plan/signin-db-move.d.ts +68 -0
  30. package/dist/plan/signin-db-move.js +338 -0
  31. package/dist/plan/signin-db-tls.d.ts +69 -0
  32. package/dist/plan/signin-db-tls.js +255 -0
  33. package/dist/plan/signin-db.d.ts +114 -0
  34. package/dist/plan/signin-db.js +255 -0
  35. package/dist/plan/uptime-worker.d.ts +20 -1
  36. package/dist/plan/uptime-worker.js +121 -3
  37. package/package.json +1 -1
  38. package/versions.digests.json +2 -1
  39. package/versions.json +2 -1
@@ -0,0 +1,255 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { pinnedRef } from '../bootstrap/versions.js';
3
+ import { envsDiffer, findOrCreatePlatformApp, resolvePlatformServer, } from './platform-providers.js';
4
+ import { SIGNIN_DB_BACKUP_ENV, SIGNIN_DB_BACKUP_KEY_SCOPE_KEY, SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY, SIGNIN_DB_BACKUP_TASK_SCOPE_KEY, ensureSigninDbBackupTarget, ensureSigninDbBackupTask, signinDbBackupEnvs, } from './signin-db-backup.js';
5
+ import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY, ensureSigninDbCa, ensureSigninDbServerCert, } from './signin-db-tls.js';
6
+ /**
7
+ * A company's sign-in database as foundry's own image (#746). Coolify 4.4.2
8
+ * cannot turn on a database's TLS and mounts files root-owned, so Postgres
9
+ * would refuse a mounted key. The database is therefore a Dockerfile app: the
10
+ * pinned `postgres` image, an entrypoint that writes the certificate and key
11
+ * from env to files owned by `postgres` with mode 0600, and Postgres started
12
+ * with `ssl=on`. ZITADEL connects with `sslmode=verify-full` and the CA.
13
+ */
14
+ export { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_BACKUP_TASK_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY };
15
+ export const SIGNIN_DB_PORT = '5432';
16
+ export const SIGNIN_DB_USER = 'zitadel';
17
+ export const SIGNIN_DB_NAME = 'zitadel';
18
+ /** Where the official image keeps its data (`PGDATA`); the volume is mounted here. */
19
+ export const SIGNIN_DB_DATA_DIR = '/var/lib/postgresql/data';
20
+ /** Where ZITADEL's container finds the CA (a Coolify file mount: the CA is public, and its image has no shell to write it). */
21
+ export const SIGNIN_DB_CA_MOUNT = '/etc/signin-db/ca.crt';
22
+ /** Inside the database container: the certificate, key and host rules the entrypoint writes. */
23
+ const TLS_DIR = '/etc/signin-db';
24
+ export const SIGNIN_DB_ENV = {
25
+ user: 'POSTGRES_USER',
26
+ password: 'POSTGRES_PASSWORD',
27
+ database: 'POSTGRES_DB',
28
+ cert: 'SIGNIN_DB_TLS_CERT_B64',
29
+ key: 'SIGNIN_DB_TLS_KEY_B64',
30
+ };
31
+ /** How long one Coolify call of these steps may take: the client has no timeout of its own. */
32
+ export const SIGNIN_DB_CALL_TIMEOUT_MS = 60_000;
33
+ /** `work`, or an error naming `what` after {@link SIGNIN_DB_CALL_TIMEOUT_MS}. The call itself is not cancelled. */
34
+ export async function boundedCall(work, what) {
35
+ let timer;
36
+ try {
37
+ return await Promise.race([
38
+ work,
39
+ new Promise((_, reject) => {
40
+ timer = setTimeout(() => reject(new Error(`${what} did not answer within ${SIGNIN_DB_CALL_TIMEOUT_MS / 1000}s`)), SIGNIN_DB_CALL_TIMEOUT_MS);
41
+ }),
42
+ ]);
43
+ }
44
+ finally {
45
+ clearTimeout(timer);
46
+ }
47
+ }
48
+ /** The port with every method bounded by {@link boundedCall}. */
49
+ export function withCallTimeout(port) {
50
+ return new Proxy(port, {
51
+ get(target, prop) {
52
+ const value = Reflect.get(target, prop, target);
53
+ if (typeof value !== 'function')
54
+ return value;
55
+ return (...args) => boundedCall(Promise.resolve(value.apply(target, args)), `Coolify ${String(prop)}`);
56
+ },
57
+ });
58
+ }
59
+ /** How long a new or redeployed database may take to report healthy. */
60
+ const DB_READY_TIMEOUT_MS = 300_000;
61
+ const DB_READY_INTERVAL_MS = 5_000;
62
+ /**
63
+ * The entrypoint, one line each. No line holds a single quote: the Dockerfile
64
+ * writes each one inside single quotes. The key file is written by `printf` to
65
+ * a path only root can reach and then handed to `postgres` with mode 0600, the
66
+ * check Postgres makes before it loads a key.
67
+ */
68
+ export function signinDbStartScript() {
69
+ const files = `${TLS_DIR}/server.crt ${TLS_DIR}/server.key ${TLS_DIR}/pg_hba.conf`;
70
+ return [
71
+ '#!/bin/sh',
72
+ 'set -eu',
73
+ `mkdir -p ${TLS_DIR}`,
74
+ `printf %s "$${SIGNIN_DB_ENV.cert}" | base64 -d > ${TLS_DIR}/server.crt`,
75
+ `printf %s "$${SIGNIN_DB_ENV.key}" | base64 -d > ${TLS_DIR}/server.key`,
76
+ `printf "%s\\n" "local all all trust" "hostssl all all all scram-sha-256" > ${TLS_DIR}/pg_hba.conf`,
77
+ `chown postgres:postgres ${files}`,
78
+ `chmod 0600 ${files}`,
79
+ `exec docker-entrypoint.sh postgres -c ssl=on -c ssl_min_protocol_version=TLSv1.2 -c ssl_cert_file=${TLS_DIR}/server.crt -c ssl_key_file=${TLS_DIR}/server.key -c hba_file=${TLS_DIR}/pg_hba.conf`,
80
+ ];
81
+ }
82
+ /**
83
+ * The inline Dockerfile. `local` connections stay trusted (inside the container only, as in the
84
+ * official image); every network connection must be TLS with a password, so a client that skips
85
+ * TLS is refused by the server, not only by the client's own setting.
86
+ */
87
+ export function signinDbDockerfile() {
88
+ const lines = signinDbStartScript();
89
+ if (lines.some((l) => l.includes("'")))
90
+ throw new Error('signin-db entrypoint holds a quote');
91
+ return [
92
+ `FROM postgres:${pinnedRef('postgres')}`,
93
+ // curl signs and puts the nightly dump into the backup bucket (`signin-db-backup.ts`).
94
+ 'RUN apk add --no-cache curl',
95
+ `RUN printf '%s\\n' \\\n${lines.map((l) => ` '${l}'`).join(' \\\n')} > /usr/local/bin/signin-db-start \\\n && chmod 0755 /usr/local/bin/signin-db-start`,
96
+ `EXPOSE ${SIGNIN_DB_PORT}`,
97
+ 'ENTRYPOINT ["/usr/local/bin/signin-db-start"]',
98
+ `HEALTHCHECK --interval=10s --timeout=5s --start-period=60s --retries=5 CMD ["pg_isready", "-U", "${SIGNIN_DB_USER}", "-d", "${SIGNIN_DB_NAME}"]`,
99
+ ].join('\n');
100
+ }
101
+ /** ZITADEL's DSN: the whole chain is checked, host name included. A secret (it holds the password). */
102
+ export function signinDbDsn(host, password) {
103
+ return `postgres://${SIGNIN_DB_USER}:${password}@${host}:${SIGNIN_DB_PORT}/${SIGNIN_DB_NAME}?sslmode=verify-full&sslrootcert=${SIGNIN_DB_CA_MOUNT}`;
104
+ }
105
+ /** The volume and file-mount calls, or an error naming the step: a host that cannot mount cannot run this database. */
106
+ function storageCalls(coolify, label) {
107
+ const { listStorages, createPersistentStorage, createFileStorage } = coolify;
108
+ if (!listStorages || !createPersistentStorage || !createFileStorage) {
109
+ throw new Error(`${label}: the host's Coolify port cannot mount the sign-in database's volume`);
110
+ }
111
+ return {
112
+ listStorages: listStorages.bind(coolify),
113
+ createPersistentStorage: createPersistentStorage.bind(coolify),
114
+ createFileStorage: createFileStorage.bind(coolify),
115
+ };
116
+ }
117
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
118
+ /**
119
+ * Polls until Coolify reports the app `running:healthy`. A healthy status read before the app was
120
+ * ever seen leaving it could be the container being replaced, so it counts only after two polls in
121
+ * a row. Throws at the deadline; the message names the app and its last status.
122
+ */
123
+ export async function waitHealthy(coolify, appId, runtime, label, what = 'the sign-in database') {
124
+ const now = runtime.now ?? Date.now;
125
+ const sleep = runtime.sleep ?? defaultSleep;
126
+ const timeout = runtime.signinDbTimeoutMs ?? DB_READY_TIMEOUT_MS;
127
+ const deadline = now() + timeout;
128
+ let left = false;
129
+ let healthyPolls = 0;
130
+ for (;;) {
131
+ const { status } = await coolify.getApplication(appId);
132
+ if (status === 'running:healthy') {
133
+ healthyPolls += 1;
134
+ if (left || healthyPolls >= 2)
135
+ return;
136
+ }
137
+ else {
138
+ left = true;
139
+ healthyPolls = 0;
140
+ }
141
+ if (now() >= deadline) {
142
+ throw new Error(`${label}: ${what} (${appId}) did not report healthy within ${Math.round(timeout / 1000)}s (last status: ${status ?? 'unknown'}); check its logs and apply again`);
143
+ }
144
+ await sleep(DB_READY_INTERVAL_MS);
145
+ }
146
+ }
147
+ /** Makes a Dockerfile app on the first usable server, without a domain: the caller's `create`. */
148
+ export function dockerfileAppCreator(coolify, o) {
149
+ return async (dockerfile) => {
150
+ const server = await resolvePlatformServer(coolify);
151
+ return coolify.createDockerfileApplication({
152
+ project_uuid: o.projectUuid,
153
+ server_uuid: server.uuid,
154
+ environment_name: o.environment.name,
155
+ environment_uuid: o.environment.uuid,
156
+ dockerfile,
157
+ name: o.name,
158
+ description: o.description,
159
+ instant_deploy: false,
160
+ autogenerate_domain: false,
161
+ });
162
+ };
163
+ }
164
+ /**
165
+ * Brings the TLS database up and returns ZITADEL's DSN: the CA and server certificate (held in
166
+ * keys), the app with its data volume and no domain, its env (the password minted once and read
167
+ * back after), and a deploy when the app is new, stopped or its env changed. Returns after the
168
+ * database reports healthy. A secret (password, key) goes only to the app's env and to
169
+ * `runtime.onSecret`.
170
+ */
171
+ export async function provisionSigninDb(input) {
172
+ const { runtime, label } = input;
173
+ const coolify = withCallTimeout(input.coolify);
174
+ if (!input.keys?.put) {
175
+ throw new Error(`${label}: the host's held keys cannot store the sign-in database certificate`);
176
+ }
177
+ if (!input.backup) {
178
+ throw new Error(`${label}: the host supplies no backup bucket for the sign-in database`);
179
+ }
180
+ const ca = await ensureSigninDbCa(input.keys, runtime, Boolean(input.recorded[SIGNIN_DB_TLS_SCOPE_KEY] ||
181
+ input.recorded[SIGNIN_DB_APP_SCOPE_KEY] ||
182
+ input.recorded[SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY]));
183
+ const server = await ensureSigninDbServerCert(input.keys, ca, input.serverCertRef, input.internalHost, runtime);
184
+ const app = await findOrCreatePlatformApp(coolify, input.name, input.environment, () => boundedCall(input.create(signinDbDockerfile()), 'Coolify createDockerfileApplication'));
185
+ await input.record(SIGNIN_DB_APP_SCOPE_KEY, app.appId);
186
+ // No `ports_mappings` and no `domains`: only the Docker network reaches the port.
187
+ await coolify.updateApplication(app.appId, {
188
+ custom_internal_name: input.internalHost,
189
+ ports_exposes: SIGNIN_DB_PORT,
190
+ health_check_enabled: false,
191
+ });
192
+ const calls = storageCalls(coolify, label);
193
+ const storages = await calls.listStorages(app.appId);
194
+ if (!(storages.persistent_storages ?? []).some((s) => s.mount_path === SIGNIN_DB_DATA_DIR)) {
195
+ await calls.createPersistentStorage(app.appId, {
196
+ name: input.volumeName,
197
+ mount_path: SIGNIN_DB_DATA_DIR,
198
+ });
199
+ }
200
+ // The password is minted once: the data directory keeps the one it was started with.
201
+ const heldEnvs = await coolify.listEnvs(app.appId);
202
+ const existing = heldEnvs.find((e) => e.key === SIGNIN_DB_ENV.password && !e.is_preview && e.value);
203
+ const heldValue = (key) => heldEnvs.find((e) => e.key === key && !e.is_preview && e.value)?.value;
204
+ const password = existing?.value ?? randomBytes(24).toString('base64url');
205
+ runtime.onSecret?.(password);
206
+ // The company's own bucket and a key that reaches it alone, never the platform's.
207
+ const backup = await ensureSigninDbBackupTarget(input.backup, {
208
+ bucketName: input.backupBucket,
209
+ label,
210
+ recorded: input.recorded,
211
+ held: {
212
+ keyId: heldValue(SIGNIN_DB_BACKUP_ENV.keyId),
213
+ secret: heldValue(SIGNIN_DB_BACKUP_ENV.secret),
214
+ },
215
+ record: input.record,
216
+ onSecret: runtime.onSecret,
217
+ });
218
+ const b64 = (pem) => Buffer.from(pem, 'utf8').toString('base64');
219
+ runtime.onSecret?.(b64(server.keyPem));
220
+ const envs = [
221
+ { key: SIGNIN_DB_ENV.user, value: SIGNIN_DB_USER },
222
+ { key: SIGNIN_DB_ENV.password, value: password },
223
+ { key: SIGNIN_DB_ENV.database, value: SIGNIN_DB_NAME },
224
+ { key: SIGNIN_DB_ENV.cert, value: b64(server.certPem) },
225
+ { key: SIGNIN_DB_ENV.key, value: b64(server.keyPem) },
226
+ ...signinDbBackupEnvs(backup.target, input.slug),
227
+ ];
228
+ const changed = await envsDiffer(coolify, app.appId, envs);
229
+ await coolify.bulkUpsertEnvs(app.appId, envs);
230
+ if (backup.fresh) {
231
+ await input.record(SIGNIN_DB_BACKUP_KEY_SCOPE_KEY, backup.tokenId);
232
+ await input.record(SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY, '');
233
+ }
234
+ if (app.justCreated || !app.running || changed) {
235
+ await coolify.deploy(app.appId);
236
+ await waitHealthy(coolify, app.appId, runtime, label);
237
+ }
238
+ await input.record(SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY, String(server.notAfter));
239
+ // The database is not used, and ZITADEL is not stopped for a move, until its nightly dump is scheduled.
240
+ await input.record(SIGNIN_DB_BACKUP_TASK_SCOPE_KEY, await ensureSigninDbBackupTask(coolify, app.appId, label, {
241
+ user: SIGNIN_DB_USER,
242
+ database: SIGNIN_DB_NAME,
243
+ }));
244
+ const dsn = signinDbDsn(input.internalHost, password);
245
+ runtime.onSecret?.(dsn);
246
+ return { appId: app.appId, dsn, ca: ca.certPem, password };
247
+ }
248
+ /** Mounts the CA on ZITADEL's app, once. The CA is public and never changes, so an existing mount is left alone. */
249
+ export async function ensureSigninDbCaFile(port, zitadelAppId, caPem, label) {
250
+ const calls = storageCalls(withCallTimeout(port), label);
251
+ const storages = await calls.listStorages(zitadelAppId);
252
+ if ((storages.file_storages ?? []).some((s) => s.mount_path === SIGNIN_DB_CA_MOUNT))
253
+ return;
254
+ await calls.createFileStorage(zitadelAppId, { mount_path: SIGNIN_DB_CA_MOUNT, content: caPem });
255
+ }
@@ -1,5 +1,6 @@
1
1
  import type { AccessPort } from './edge-access.js';
2
2
  import type { PlatformWorkersPort } from './edge-workers.js';
3
+ import type { CoolifyPort } from './executor.js';
3
4
  import type { PlatformStepExecutionContext } from './plan-apply.js';
4
5
  /** The optional plain text variables the Worker reads, supplied by the host (not secrets). */
5
6
  export declare const UPTIME_OPTIONAL_VARS: readonly ["CHAT_HOMESERVER_URL", "CHAT_ROOM_ID", "ALERT_EMAIL_FROM", "ALERT_EMAIL_TO"];
@@ -9,6 +10,19 @@ export type PlatformWorkersFactory = (edgeUrl: string) => PlatformWorkersPort |
9
10
  export type PlatformUptimeAccessPort = Pick<AccessPort, 'createServiceToken' | 'removeServiceToken' | 'findServiceTokenByName'>;
10
11
  /** Builds the Access port at edge's recorded address, the {@link PlatformWorkersFactory} way. */
11
12
  export type PlatformUptimeAccessFactory = (edgeUrl: string) => PlatformUptimeAccessPort | Promise<PlatformUptimeAccessPort>;
13
+ /**
14
+ * What the uptime step needs to make its chat bot through chat-service. The bearer for
15
+ * `/_chat/internal/*` is `CHAT_INTERNAL_TOKEN` on Company Included's chat-service app, read here and
16
+ * never held, logged or put in an error.
17
+ */
18
+ export interface PlatformUptimeChatDeps {
19
+ /** Reads that app's env (`OwnerChat.appId`). */
20
+ coolify: Pick<CoolifyPort, 'listEnvs'>;
21
+ /** The HTTP client; the global `fetch` by default. */
22
+ fetch?: typeof fetch;
23
+ /** Milliseconds one call may take (default 10 seconds). */
24
+ timeoutMs?: number;
25
+ }
12
26
  /**
13
27
  * Dependencies of the uptime Worker step. `deliverSecret` and `heldKeys` are
14
28
  * the host's: the minted token goes only to `deliverSecret`, and every secret
@@ -26,8 +40,13 @@ export interface PlatformUptimeDeps {
26
40
  heldKeys: {
27
41
  resolve(ref: string): Promise<string | null>;
28
42
  };
29
- /** Host config for the Worker's optional plain text variables; an absent one is left unset. */
43
+ /**
44
+ * Host config for the Worker's optional plain text variables; an absent one is left unset. A set
45
+ * `CHAT_HOMESERVER_URL` or `CHAT_ROOM_ID` overrides the one foundry made.
46
+ */
30
47
  vars?: Partial<Record<(typeof UPTIME_OPTIONAL_VARS)[number], string>>;
48
+ /** Makes the Worker's chat bot and its token. Absent: the chat credential is held by hand. */
49
+ chat?: PlatformUptimeChatDeps;
31
50
  }
32
51
  /**
33
52
  * What is held now: the optional secrets, and `UPTIME_HEARTBEAT_URL` (a ping
@@ -1,7 +1,7 @@
1
1
  import { randomBytes, randomUUID } from 'node:crypto';
2
2
  import { EdgeApiError } from '@wtfalch/edge';
3
3
  import { uptimeArtifactUrl } from '../platform.js';
4
- import { PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_TAG_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_URL_REF, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_KV_TITLE, PLATFORM_UPTIME_OPTIONAL_SECRETS, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY, uptimeAccessTokenConflict, uptimeSecretNames, uptimeVarsDigest, } from './platform-diff.js';
4
+ import { PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_TAG_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY, PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY, PLATFORM_UPTIME_CHAT_TOKEN_REF, PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_URL_REF, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_KV_TITLE, PLATFORM_UPTIME_OPTIONAL_SECRETS, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY, uptimeAccessTokenConflict, uptimeEffectiveVars, uptimeSecretNames, uptimeVarsDigest, } from './platform-diff.js';
5
5
  /** The Worker's cron trigger, from Archon's `workers/uptime/manifest.json`. */
6
6
  const UPTIME_CRONS = ['* * * * *'];
7
7
  /** The date Archon's own Worker config used. */
@@ -15,6 +15,84 @@ export const UPTIME_OPTIONAL_VARS = [
15
15
  'ALERT_EMAIL_FROM',
16
16
  'ALERT_EMAIL_TO',
17
17
  ];
18
+ /** How long one call to read chat-service's token or to ask it for the bot may take. */
19
+ const CHAT_CALL_TIMEOUT_MS = 10_000;
20
+ /** The bot's localpart in chat-service (`@uptime-bot:<server>`). */
21
+ const CHAT_BOT_LOCALPART = 'uptime-bot';
22
+ /** Runs `work` and gives up after `ms`; the error names `what` only. */
23
+ async function withinMs(ms, what, work) {
24
+ let timer;
25
+ try {
26
+ return await Promise.race([
27
+ work,
28
+ new Promise((_, reject) => {
29
+ timer = setTimeout(() => reject(new Error(`platform:uptime: ${what} did not answer within ${ms / 1000}s`)), ms);
30
+ }),
31
+ ]);
32
+ }
33
+ finally {
34
+ clearTimeout(timer);
35
+ }
36
+ }
37
+ /**
38
+ * `PUT /_chat/internal/bots/uptime-bot` on the company's Agora host. Idempotent on the chat-service
39
+ * side: a repeat returns the room and no token unless `rotate` is set, so calling with `rotate` again
40
+ * revokes the previous token. Every message here is fixed text: the response body, the bearer and the
41
+ * token never reach an error.
42
+ */
43
+ async function callChatBot(chat, owner, rotate) {
44
+ const timeoutMs = chat.timeoutMs ?? CHAT_CALL_TIMEOUT_MS;
45
+ let envs;
46
+ try {
47
+ envs = await withinMs(timeoutMs, 'reading the chat-service app', chat.coolify.listEnvs(owner.appId));
48
+ }
49
+ catch {
50
+ // The cause can carry a response body.
51
+ throw new Error('platform:uptime: the chat-service app could not be read');
52
+ }
53
+ const bearer = envs.find((e) => e.key === 'CHAT_INTERNAL_TOKEN' && !e.is_preview)?.value;
54
+ if (typeof bearer !== 'string' || !/^\S{32,}$/.test(bearer)) {
55
+ throw new Error('platform:uptime: chat-service holds no usable CHAT_INTERNAL_TOKEN');
56
+ }
57
+ const controller = new AbortController();
58
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
59
+ try {
60
+ const response = await (chat.fetch ?? fetch)(`https://${owner.agoraHost}/_chat/internal/bots/${CHAT_BOT_LOCALPART}`, {
61
+ method: 'PUT',
62
+ headers: { authorization: `Bearer ${bearer}`, 'content-type': 'application/json' },
63
+ body: JSON.stringify({
64
+ displayName: 'Platform alerts',
65
+ room: { name: 'platform-alerts' },
66
+ members: [...owner.subjects],
67
+ ...(rotate ? { rotate: true } : {}),
68
+ }),
69
+ redirect: 'error',
70
+ signal: controller.signal,
71
+ });
72
+ if (response.status !== 200) {
73
+ throw new Error(`platform:uptime: chat-service refused the bot request (HTTP ${response.status})`);
74
+ }
75
+ const body = (await response.json());
76
+ const { userId, roomId, accessToken } = body ?? {};
77
+ if (typeof userId !== 'string' ||
78
+ !userId ||
79
+ typeof roomId !== 'string' ||
80
+ !roomId ||
81
+ (accessToken !== undefined && (typeof accessToken !== 'string' || !accessToken))) {
82
+ throw new Error('platform:uptime: chat-service answered the bot request with an unusable body');
83
+ }
84
+ return { userId, roomId, ...(accessToken ? { accessToken } : {}) };
85
+ }
86
+ catch (error) {
87
+ // Our own messages pass; anything else (a network error, a bad JSON body) is replaced.
88
+ if (error instanceof Error && error.message.startsWith('platform:uptime: '))
89
+ throw error;
90
+ throw new Error('platform:uptime: chat-service did not answer the bot request');
91
+ }
92
+ finally {
93
+ clearTimeout(timer);
94
+ }
95
+ }
18
96
  /**
19
97
  * What is held now: the optional secrets, and `UPTIME_HEARTBEAT_URL` (a ping
20
98
  * URL that works as a capability, so a secret too), name to value. Callers
@@ -108,11 +186,51 @@ export async function provisionPlatformUptime(context, deps) {
108
186
  }
109
187
  const hostname = platform.archon.hostname;
110
188
  const held = await heldUptimeValues(deps);
189
+ // The chat bot comes before the upload: its room is one of the Worker's variables. A call that fails
190
+ // stops the step here, and the next apply makes it again (the marker below says whether a token may
191
+ // already have been minted).
192
+ const ownerChat = context.owner?.chat ?? null;
193
+ let chatRoomId = recorded[PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY] || '';
194
+ if (deps.chat && ownerChat && ownerChat.subjects.length > 0) {
195
+ const lost = recorded[PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY];
196
+ if (held.CHAT_ACCESS_TOKEN) {
197
+ if (lost) {
198
+ context.recordScopeId(PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY, '');
199
+ await context.persist?.();
200
+ }
201
+ }
202
+ else {
203
+ // Recorded and persisted before the route is called, because the call is what mints the token.
204
+ if (!lost) {
205
+ context.recordScopeId(PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY, randomUUID());
206
+ await context.persist?.();
207
+ }
208
+ // A marker, or a room already made, means the bot exists and its token is not held: rotate.
209
+ const rotate = Boolean(lost) || Boolean(chatRoomId);
210
+ let bot = await callChatBot(deps.chat, ownerChat, rotate);
211
+ if (!bot.accessToken && !rotate)
212
+ bot = await callChatBot(deps.chat, ownerChat, true);
213
+ const chatToken = bot.accessToken;
214
+ if (!chatToken) {
215
+ throw new Error('platform:uptime: chat-service returned no token for the bot, even when asked to rotate it');
216
+ }
217
+ chatRoomId = bot.roomId;
218
+ context.recordScopeId(PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY, chatRoomId);
219
+ await context.persist?.();
220
+ await attempt('holding the chat bot token', () => deps.deliverSecret(PLATFORM_UPTIME_CHAT_TOKEN_REF, chatToken));
221
+ context.recordScopeId(PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY, '');
222
+ await context.persist?.();
223
+ held.CHAT_ACCESS_TOKEN = chatToken;
224
+ }
225
+ }
226
+ const workerVars = uptimeEffectiveVars(deps.vars, deps.chat && ownerChat && chatRoomId
227
+ ? { homeserverUrl: `https://${ownerChat.agoraHost}`, roomId: chatRoomId }
228
+ : null);
111
229
  const bindings = [
112
230
  { type: 'kv_namespace', name: 'UPTIME_KV', title: PLATFORM_UPTIME_KV_TITLE },
113
231
  { type: 'plain_text', name: 'ARCHON_BASE_URL', text: `https://${hostname}` },
114
232
  ...UPTIME_OPTIONAL_VARS.flatMap((name) => {
115
- const text = deps.vars?.[name];
233
+ const text = workerVars[name];
116
234
  return text ? [{ type: 'plain_text', name, text }] : [];
117
235
  }),
118
236
  ];
@@ -223,7 +341,7 @@ export async function provisionPlatformUptime(context, deps) {
223
341
  context.recordScopeId(PLATFORM_UPTIME_APPLIED_TAG_SCOPE_KEY, uptime.tag);
224
342
  context.recordScopeId(PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY, uptime.sha256);
225
343
  context.recordScopeId(PLATFORM_UPTIME_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
226
- context.recordScopeId(PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY, uptimeVarsDigest(deps.vars));
344
+ context.recordScopeId(PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY, uptimeVarsDigest(workerVars));
227
345
  context.recordScopeId(PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY, uptimeSecretNames(Object.keys(held)));
228
346
  return { resourceId: PLATFORM_UPTIME_SCRIPT_NAME };
229
347
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
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",
@@ -7,5 +7,6 @@
7
7
  "stalwart": "sha256:ec011be228596e37e65f41aab17deed573859614430472f7eeb42178c50d87b7",
8
8
  "hc": "sha256:aa08a61b0dcfd83f1332eab195d5cec1b595ae076eb8388cadf8a3d597114f9b",
9
9
  "alpine": "sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1",
10
- "busybox": "sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092"
10
+ "busybox": "sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092",
11
+ "postgres": "sha256:786dab398303b8ce7cb76b407bb21ef2e4dfbbbd4c6abcf3d29b3130467ffdbc"
11
12
  }
package/versions.json CHANGED
@@ -7,5 +7,6 @@
7
7
  "stalwart": "v0.16.24",
8
8
  "hc": "v4.4",
9
9
  "alpine": "3.22.1",
10
- "busybox": "1.37.0-musl"
10
+ "busybox": "1.37.0-musl",
11
+ "postgres": "16.14-alpine3.22"
11
12
  }