@wtfalch/foundry 0.27.0 → 0.29.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.
@@ -35,9 +35,48 @@
35
35
  * live outside this package. `calls-sfu-ssh-adapter.ts`'s {@link CallsSfuSshSession} is the narrow shape
36
36
  * a test fakes instead.
37
37
  */
38
+ import { createHash } from 'node:crypto';
38
39
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
39
40
  import { mintLivekitCredentials } from '../calls-sfu-keys.js';
40
41
  import { applyCallsSfuOverSession } from '../calls-sfu-ssh-adapter.js';
42
+ import { CALLS_SFU_FIREWALL_COMMANDS, CALLS_SFU_SSH_RULE_COMMAND, CALLS_SFU_UP_COMMAND, cloudflaredEnvFile, dockerComposeYaml, livekitConfigYaml, } from '../calls-sfu.js';
43
+ /**
44
+ * Hash of what `applyCallsSfu` writes and runs, rendered from placeholder
45
+ * values so no secret is involved: a change to the compose file, the
46
+ * `livekit.yaml` template, the env file template or a command changes it.
47
+ */
48
+ function callsSfuFilesVersion() {
49
+ const placeholder = {
50
+ address: 'address',
51
+ livekitKeys: { key: 'secret' },
52
+ cloudflaredTunnelToken: 'token',
53
+ };
54
+ return createHash('sha256')
55
+ .update(JSON.stringify([
56
+ dockerComposeYaml(),
57
+ livekitConfigYaml(placeholder),
58
+ cloudflaredEnvFile(placeholder.cloudflaredTunnelToken),
59
+ CALLS_SFU_UP_COMMAND,
60
+ CALLS_SFU_SSH_RULE_COMMAND,
61
+ CALLS_SFU_FIREWALL_COMMANDS,
62
+ ]))
63
+ .digest('hex');
64
+ }
65
+ /**
66
+ * What the last successful `sfu` apply is recorded as
67
+ * (`PLATFORM_SFU_APPLIED_SCOPE_KEY`): a SHA-256 hex digest of foundry's calls
68
+ * files, the sorted distinct org slugs and the connection revision. It holds
69
+ * no secret and does not depend on slug order or duplicates.
70
+ */
71
+ export function callsSfuAppliedFingerprint(input) {
72
+ return createHash('sha256')
73
+ .update(JSON.stringify([
74
+ callsSfuFilesVersion(),
75
+ [...new Set(input.orgSlugs)].sort(),
76
+ input.connectionRevision ?? '',
77
+ ]))
78
+ .digest('hex');
79
+ }
41
80
  /**
42
81
  * The key id the platform's own LiveKit pair is held under, alongside every
43
82
  * org's own pair, in the same {@link CallsSfuKeysPort} (#152 round 2 review:
@@ -1,7 +1,7 @@
1
1
  import type { CloudflareClient } from '@wtfalch/providers/cloudflare';
2
2
  import type { CoolifyClient } from '@wtfalch/providers/coolify';
3
3
  import { type ZitadelClient } from '@wtfalch/providers/zitadel';
4
- import type { OrgId } from '../org.js';
4
+ import { type OrgId } from '../org.js';
5
5
  import type { DeclaredOrgStore } from '../store.js';
6
6
  import type { AiReader, CloudflareReader, CmsReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
7
7
  /** The `CoolifyClient` methods the readers call, so a host's own implementation needs no cast. */
@@ -1,5 +1,6 @@
1
1
  import { ZitadelError } from '@wtfalch/providers/zitadel';
2
- import { BOULE_MANAGED_ENV_KEYS } from './boule.js';
2
+ import { APP_ENTRY_KEY } from '../org.js';
3
+ import { BOULE_MAIL_TOKEN_ENV, BOULE_MANAGED_ENV_KEYS } from './boule.js';
3
4
  /**
4
5
  * A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
5
6
  * — the one place foundry records a provider's own id for an org today,
@@ -247,7 +248,7 @@ async function withAgoraEnvConfig(client, resource, recorded) {
247
248
  * `executor.ts` would write. Gated and failing exactly like
248
249
  * {@link withAgoraEnvConfig}.
249
250
  */
250
- async function withBouleEnvConfig(client, resource, recorded) {
251
+ async function withBouleEnvConfig(client, resource, recorded, liveKeys) {
251
252
  if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('boule:')) {
252
253
  return resource;
253
254
  }
@@ -257,7 +258,20 @@ async function withBouleEnvConfig(client, resource, recorded) {
257
258
  if (!e.is_preview && managed.includes(e.key))
258
259
  env[e.key] = e.value;
259
260
  }
260
- return { ...resource, config: { ...resource.config, env } };
261
+ // The hosted app's Lettermint token, as `config.appMailToken`: the one
262
+ // Boule must hold too. In memory only, compared by `planBoule` and never
263
+ // written to a step, description or log.
264
+ const orgId = recorded[resource.key]?.slice('boule:'.length);
265
+ const appUuid = Object.entries(recorded).find(([, value]) => value === `app:${orgId}:${APP_ENTRY_KEY}`)?.[0];
266
+ // A recorded app that is not in the live listing is gone: no app token, and
267
+ // no read of its envs. A listed app whose read fails fails like any read.
268
+ const appToken = appUuid && liveKeys.has(appUuid)
269
+ ? (await client.listEnvs(appUuid)).find((e) => !e.is_preview && e.key === BOULE_MAIL_TOKEN_ENV)?.value
270
+ : undefined;
271
+ return {
272
+ ...resource,
273
+ config: { ...resource.config, env, ...(appToken ? { appMailToken: appToken } : {}) },
274
+ };
261
275
  }
262
276
  /** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
263
277
  function toFoundryResources(system, resources) {
@@ -290,7 +304,8 @@ export class ProvidersCoolifyReader {
290
304
  const withEnvNames = await Promise.all(withHealthCheck.map((r) => withEnvNamesConfig(this.client, r, recorded)));
291
305
  const withDbUrl = await Promise.all(withEnvNames.map((r) => withDatabaseUrlConfig(this.client, r, recorded)));
292
306
  const withAgoraConfig = await Promise.all(withDbUrl.map((r) => withAgoraEnvConfig(this.client, r, recorded)));
293
- const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded)));
307
+ const liveKeys = new Set(withAgoraConfig.map((r) => r.key));
308
+ const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded, liveKeys)));
294
309
  return applyRecordedIds(withBouleConfig, recorded);
295
310
  }
296
311
  /** The project's resources as Coolify reports them, unmapped: what adopt checks against a declaration. */
@@ -12,6 +12,14 @@ export interface PlatformService {
12
12
  hostname: string;
13
13
  gitRef: string;
14
14
  }
15
+ /**
16
+ * `sfu`'s own extra field. A host that changes what the SFU VM is reached
17
+ * with (its SSH key, host key or tunnel token) sets a new `connectionRevision`
18
+ * so the platform plan shows the `sfu` step again; foundry never reads it.
19
+ */
20
+ export interface PlatformSfuService extends PlatformService {
21
+ connectionRevision?: string;
22
+ }
15
23
  /**
16
24
  * #491: `mail`'s own extra field, on top of every other service's shape.
17
25
  * Company Included (or whichever org's mail domain is the platform's own
@@ -76,7 +84,7 @@ export interface DeclaredPlatform {
76
84
  serverIpv4?: string | null;
77
85
  auth: PlatformService;
78
86
  mail: PlatformMailService;
79
- sfu: PlatformService;
87
+ sfu: PlatformSfuService;
80
88
  files: PlatformService;
81
89
  ai: PlatformService;
82
90
  jobs: PlatformService;
@@ -116,6 +124,8 @@ export declare function defaultPlatformHostname(service: PlatformServiceKey, ape
116
124
  * - `serverIpv4`, when declared, is a dotted-quad IPv4 address.
117
125
  * - Every service's `hostname` is the apex itself or a subdomain of it -- a
118
126
  * platform service does not live outside the platform's own apex.
127
+ * - `sfu.connectionRevision`, when declared, is at most 64 characters of
128
+ * `A-Za-z0-9:.-`.
119
129
  * - Every service's `gitRef` is non-empty once that service is `enabled` --
120
130
  * an enabled service with nothing to deploy is not yet declared.
121
131
  */
package/dist/platform.js CHANGED
@@ -32,6 +32,8 @@ function serviceOf(platform, key) {
32
32
  * - `serverIpv4`, when declared, is a dotted-quad IPv4 address.
33
33
  * - Every service's `hostname` is the apex itself or a subdomain of it -- a
34
34
  * platform service does not live outside the platform's own apex.
35
+ * - `sfu.connectionRevision`, when declared, is at most 64 characters of
36
+ * `A-Za-z0-9:.-`.
35
37
  * - Every service's `gitRef` is non-empty once that service is `enabled` --
36
38
  * an enabled service with nothing to deploy is not yet declared.
37
39
  */
@@ -54,6 +56,10 @@ export function validatePlatform(platform) {
54
56
  return `${key}.gitRef must not be empty once ${key}.enabled is true`;
55
57
  }
56
58
  }
59
+ const revision = platform.sfu.connectionRevision;
60
+ if (revision != null && !/^[A-Za-z0-9:.-]{0,64}$/.test(revision)) {
61
+ return 'sfu.connectionRevision must be at most 64 characters of A-Z, a-z, 0-9, ":", "." and "-"';
62
+ }
57
63
  const { uptime } = platform;
58
64
  if (Boolean(uptime.tag.trim()) !== Boolean(uptime.sha256.trim())) {
59
65
  return 'uptime.tag and uptime.sha256 must be set together: a pin is both';
@@ -606,7 +606,12 @@ function rowToPlatform(row) {
606
606
  gitRef: row.mailGitRef,
607
607
  ...(row.mailLettermintProjectId ? { lettermintProjectId: row.mailLettermintProjectId } : {}),
608
608
  },
609
- sfu: { enabled: row.sfuEnabled, hostname: row.sfuHostname, gitRef: row.sfuGitRef },
609
+ sfu: {
610
+ enabled: row.sfuEnabled,
611
+ hostname: row.sfuHostname,
612
+ gitRef: row.sfuGitRef,
613
+ ...(row.sfuConnectionRevision ? { connectionRevision: row.sfuConnectionRevision } : {}),
614
+ },
610
615
  files: { enabled: row.filesEnabled, hostname: row.filesHostname, gitRef: row.filesGitRef },
611
616
  ai: { enabled: row.aiEnabled, hostname: row.aiHostname, gitRef: row.aiGitRef },
612
617
  jobs: { enabled: row.jobsEnabled, hostname: row.jobsHostname, gitRef: row.jobsGitRef },
@@ -654,6 +659,7 @@ function platformToRow(platform) {
654
659
  sfuEnabled: platform.sfu.enabled,
655
660
  sfuHostname: platform.sfu.hostname,
656
661
  sfuGitRef: platform.sfu.gitRef,
662
+ sfuConnectionRevision: platform.sfu.connectionRevision ?? null,
657
663
  filesEnabled: platform.files.enabled,
658
664
  filesHostname: platform.files.hostname,
659
665
  filesGitRef: platform.files.gitRef,
@@ -1643,6 +1643,23 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
1643
1643
  identity: undefined;
1644
1644
  generated: undefined;
1645
1645
  }, {}, {}>;
1646
+ sfuConnectionRevision: import("drizzle-orm/pg-core").PgColumn<{
1647
+ name: "sfu_connection_revision";
1648
+ tableName: "foundry_declared_platform";
1649
+ dataType: "string";
1650
+ columnType: "PgText";
1651
+ data: string;
1652
+ driverParam: string;
1653
+ notNull: false;
1654
+ hasDefault: false;
1655
+ isPrimaryKey: false;
1656
+ isAutoincrement: false;
1657
+ hasRuntimeDefault: false;
1658
+ enumValues: [string, ...string[]];
1659
+ baseColumn: never;
1660
+ identity: undefined;
1661
+ generated: undefined;
1662
+ }, {}, {}>;
1646
1663
  filesEnabled: import("drizzle-orm/pg-core").PgColumn<{
1647
1664
  name: "files_enabled";
1648
1665
  tableName: "foundry_declared_platform";
@@ -165,6 +165,8 @@ export const foundryDeclaredPlatform = pgTable('foundry_declared_platform', {
165
165
  sfuEnabled: boolean('sfu_enabled').notNull().default(false),
166
166
  sfuHostname: text('sfu_hostname').notNull(),
167
167
  sfuGitRef: text('sfu_git_ref').notNull().default(''),
168
+ /** `migrations/0050_platform_sfu_connection_revision.sql`. Declared; null means unset. */
169
+ sfuConnectionRevision: text('sfu_connection_revision'),
168
170
  filesEnabled: boolean('files_enabled').notNull().default(false),
169
171
  filesHostname: text('files_hostname').notNull(),
170
172
  filesGitRef: text('files_git_ref').notNull().default(''),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.27.0",
3
+ "version": "0.29.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",