@wtfalch/foundry 0.4.0 → 0.6.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 (76) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +111 -0
  2. package/dist/bootstrap/bootstrap.js +323 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +99 -0
  5. package/dist/bootstrap/drive.d.ts +40 -0
  6. package/dist/bootstrap/drive.js +51 -0
  7. package/dist/bootstrap/index.d.ts +3 -0
  8. package/dist/bootstrap/index.js +1 -0
  9. package/dist/calls-sfu-deploy-support.d.ts +17 -6
  10. package/dist/calls-sfu-deploy-support.js +19 -7
  11. package/dist/calls-sfu-keys.d.ts +21 -0
  12. package/dist/calls-sfu-keys.js +27 -0
  13. package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
  14. package/dist/calls-sfu-ssh-adapter.js +78 -0
  15. package/dist/calls-sfu-ssh-client.d.ts +49 -0
  16. package/dist/calls-sfu-ssh-client.js +82 -0
  17. package/dist/calls-sfu.d.ts +36 -12
  18. package/dist/calls-sfu.js +41 -9
  19. package/dist/index.d.ts +38 -7
  20. package/dist/index.js +20 -4
  21. package/dist/migrations/0016_platform.sql +61 -0
  22. package/dist/migrations/0017_requested_changes.sql +42 -0
  23. package/dist/migrations/0018_platform_apply.sql +19 -0
  24. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  25. package/dist/migrations/0020_mail_apps.sql +7 -0
  26. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  27. package/dist/org.d.ts +81 -0
  28. package/dist/org.js +96 -0
  29. package/dist/plan/agora.d.ts +77 -0
  30. package/dist/plan/agora.js +127 -0
  31. package/dist/plan/cloudflared-connector.d.ts +70 -0
  32. package/dist/plan/cloudflared-connector.js +106 -0
  33. package/dist/plan/diff.d.ts +20 -3
  34. package/dist/plan/diff.js +713 -115
  35. package/dist/plan/executor.d.ts +680 -172
  36. package/dist/plan/executor.js +755 -189
  37. package/dist/plan/index.d.ts +28 -5
  38. package/dist/plan/index.js +15 -2
  39. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  40. package/dist/plan/jobs-scheduler-http.js +50 -0
  41. package/dist/plan/live.d.ts +51 -2
  42. package/dist/plan/live.js +8 -0
  43. package/dist/plan/mail-copy.d.ts +125 -0
  44. package/dist/plan/mail-copy.js +166 -0
  45. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  46. package/dist/plan/mail-identity-allowlist.js +11 -0
  47. package/dist/plan/mail-identity.d.ts +68 -0
  48. package/dist/plan/mail-identity.js +17 -0
  49. package/dist/plan/plan-apply.d.ts +197 -3
  50. package/dist/plan/plan-apply.js +278 -9
  51. package/dist/plan/platform-diff.d.ts +487 -0
  52. package/dist/plan/platform-diff.js +1220 -0
  53. package/dist/plan/platform-org-ports.d.ts +50 -0
  54. package/dist/plan/platform-org-ports.js +70 -0
  55. package/dist/plan/platform-org.d.ts +121 -0
  56. package/dist/plan/platform-org.js +115 -0
  57. package/dist/plan/platform-providers.d.ts +615 -0
  58. package/dist/plan/platform-providers.js +4410 -0
  59. package/dist/plan/platform-sfu.d.ts +128 -0
  60. package/dist/plan/platform-sfu.js +142 -0
  61. package/dist/plan/providers-readers.js +22 -1
  62. package/dist/plan/zitadel-machine-user.d.ts +83 -0
  63. package/dist/plan/zitadel-machine-user.js +238 -0
  64. package/dist/plan.d.ts +6 -0
  65. package/dist/platform.d.ts +55 -0
  66. package/dist/platform.js +46 -0
  67. package/dist/requested-changes.d.ts +118 -0
  68. package/dist/requested-changes.js +125 -0
  69. package/dist/slug.js +15 -2
  70. package/dist/store/audit.js +4 -2
  71. package/dist/store/postgres.d.ts +29 -1
  72. package/dist/store/postgres.js +203 -4
  73. package/dist/store/schema.d.ts +755 -0
  74. package/dist/store/schema.js +70 -0
  75. package/dist/store.d.ts +53 -0
  76. package/package.json +1 -1
@@ -0,0 +1,127 @@
1
+ /**
2
+ * #164: what both the planner (`diff.ts`) and the executor (`executor.ts`)
3
+ * need to agree on for an org's Agora -- the hostname, the values the host
4
+ * supplies, and the env vars those produce -- so the plan can compare the
5
+ * live app with exactly what the executor would write.
6
+ */
7
+ /**
8
+ * `AppliedState.serviceIds` scope keys `desiredAgoraEnv` reads:
9
+ * `zitadel_org` is `executor.ts`'s `ensureZitadelProject`/`zitadelIssuer`
10
+ * scope id, `agora_client_id` the fixed `agora` ZITADEL client's `clientId`
11
+ * (recorded by `executor.ts`'s `zitadelApp`).
12
+ */
13
+ export const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
14
+ export const AGORA_CLIENT_ID_SCOPE_KEY = 'agora_client_id';
15
+ /**
16
+ * The accent every org's Agora wears until orgs choose their own: the house
17
+ * theme's `--accent` (`@wtfalch/house`, `HOUSE_THEMES['good-company']` in
18
+ * `design/packages/house/src/themes.ts`, the default theme
19
+ * `HOUSE_DEFAULT_THEME`). Agora's config parser requires `theme.accent` as a
20
+ * hex colour (`agora/apps/agora/src/agoras.ts`). Restated here rather than
21
+ * imported: `packages/foundry` carries no `@wtfalch/house` dependency.
22
+ */
23
+ export const AGORA_THEME_ACCENT = '#B23A0C';
24
+ /** No host values at all: what an absent port means. */
25
+ export const NO_AGORA_HOST_VALUES = {
26
+ issuer: null,
27
+ mailProjectId: null,
28
+ messagingProjectId: null,
29
+ mailUpstream: null,
30
+ filesUpstream: null,
31
+ };
32
+ /** Reads the port once for a plan. An absent port, or one that throws, is a blocked Agora, never a failed plan. */
33
+ export async function readAgoraHost(port, orgId) {
34
+ if (!port)
35
+ return NO_AGORA_HOST_VALUES;
36
+ try {
37
+ return await port.valuesFor(orgId);
38
+ }
39
+ catch (error) {
40
+ return { error: error instanceof Error ? error.message : String(error) };
41
+ }
42
+ }
43
+ /** The host values Agora cannot be configured without, by name -- the ones `host` lacks. */
44
+ export function agoraHostMissing(host) {
45
+ const missing = [];
46
+ if (!host.issuer)
47
+ missing.push('issuer');
48
+ if (!host.mailProjectId)
49
+ missing.push('mailProjectId');
50
+ if (!host.messagingProjectId)
51
+ missing.push('messagingProjectId');
52
+ return missing;
53
+ }
54
+ /**
55
+ * `agora.<domain>` when the org has a domain, `<slug>-agora.oikos.host`
56
+ * otherwise (the 2026-09-24 layout, foundry#14; `../slug.ts`'s
57
+ * `AGORA_SUFFIX` reserves the collision).
58
+ */
59
+ export function agoraHostnameFor(org) {
60
+ return org.domain ? `agora.${org.domain}` : `${org.slug}-agora.oikos.host`;
61
+ }
62
+ /**
63
+ * The env vars an org's Agora app should carry, and every input still
64
+ * missing to build them. `AGORA_CONFIG` is present only when `missing` is
65
+ * empty; callers that write must refuse on a non-empty `missing`.
66
+ * `AGORA_MAIL_UPSTREAM` is present only when mail is on and the host supplies
67
+ * an address.
68
+ */
69
+ export function desiredAgoraEnv(org, recorded, host) {
70
+ const env = { AGORA_CANONICAL_HOST: agoraHostnameFor(org) };
71
+ const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
72
+ const clientId = recorded[AGORA_CLIENT_ID_SCOPE_KEY];
73
+ const missing = [];
74
+ if (!zitadelOrgId)
75
+ missing.push('zitadelOrgId');
76
+ if (!clientId)
77
+ missing.push('clientId');
78
+ missing.push(...agoraHostMissing(host));
79
+ if (missing.length === 0) {
80
+ env.AGORA_CONFIG = JSON.stringify({
81
+ name: org.displayName,
82
+ theme: { accent: AGORA_THEME_ACCENT },
83
+ issuer: host.issuer,
84
+ clientId,
85
+ organisationId: zitadelOrgId,
86
+ mailProjectId: host.mailProjectId,
87
+ messagingProjectId: host.messagingProjectId,
88
+ });
89
+ }
90
+ if (org.mail.mode !== 'off' && host.mailUpstream) {
91
+ env.AGORA_MAIL_UPSTREAM = host.mailUpstream;
92
+ }
93
+ // Files is always on for every org (#80), unlike mail -- no mode to gate
94
+ // this on, only whether the host supplies an address at all (#177).
95
+ if (host.filesUpstream) {
96
+ env.AGORA_FILES_UPSTREAM = host.filesUpstream;
97
+ }
98
+ return { env, missing };
99
+ }
100
+ function stable(value) {
101
+ if (Array.isArray(value))
102
+ return `[${value.map(stable).join(',')}]`;
103
+ if (value && typeof value === 'object') {
104
+ const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : 1);
105
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stable(v)}`).join(',')}}`;
106
+ }
107
+ return JSON.stringify(value);
108
+ }
109
+ function sameEnvValue(key, desired, live) {
110
+ if (key !== 'AGORA_CONFIG')
111
+ return desired === live;
112
+ try {
113
+ return stable(JSON.parse(desired)) === stable(JSON.parse(live));
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ /** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
120
+ export function agoraEnvDrift(desired, live) {
121
+ return Object.entries(desired)
122
+ .filter(([key, value]) => {
123
+ const liveValue = live[key];
124
+ return liveValue === undefined || !sameEnvValue(key, value, liveValue);
125
+ })
126
+ .map(([key]) => key);
127
+ }
@@ -0,0 +1,70 @@
1
+ import type { CloudflarePort, CoolifyPort } from './executor.js';
2
+ /**
3
+ * #197: the pinned `cloudflared` image, the same release tag as
4
+ * `scripts/shared-ingress.mjs`'s and `calls-sfu.ts`'s own
5
+ * `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal.
6
+ */
7
+ export declare const CLOUDFLARED_IMAGE_REF = "cloudflare/cloudflared:2026.9.3";
8
+ /**
9
+ * The inline Dockerfile a docker-image `cloudflared` app needs, copied from
10
+ * `scripts/shared-ingress.mjs`'s `cloudflaredDockerfile()`: the published
11
+ * image's own `CMD` is `version`, and Coolify's `start_command` is ignored
12
+ * for image-based applications, so the run command has to be stated in a
13
+ * Dockerfile's `CMD` instead. `TUNNEL_TOKEN` is never baked in here; it
14
+ * reaches the container only via `bulkUpsertEnvs`, as an environment
15
+ * variable `cloudflared` reads at boot.
16
+ */
17
+ export declare function cloudflaredDockerfile(): string;
18
+ /**
19
+ * #197: ensure one `cloudflared` connector app exists in Coolify for a
20
+ * tunnel, with the current token, deployed if needed. Decision: one connector
21
+ * per platform tunnel, so each tunnel-owning platform step (auth, mail,
22
+ * archon) calls this once with its own app name and scope key.
23
+ */
24
+ export interface EnsureCloudflaredConnectorInput {
25
+ coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'createDockerfileApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy'>;
26
+ cloudflare: Pick<CloudflarePort, 'getTunnelToken'>;
27
+ /** The Cloudflare tunnel this connector dials into (already created and configured by the caller). */
28
+ tunnelId: string;
29
+ /** Fixed name, found before created -- so a second apply reuses this app rather than making a second one. */
30
+ appName: string;
31
+ projectUuid: string;
32
+ environment: {
33
+ id: number;
34
+ name: string;
35
+ uuid: string;
36
+ };
37
+ /** Called only when the app must be created -- never on the found-by-name or found-by-id paths. */
38
+ resolveServer: () => Promise<{
39
+ uuid: string;
40
+ }>;
41
+ /**
42
+ * The app's uuid from a prior run's recorded scope id, or `null`. When
43
+ * given, it is trusted over a by-name lookup, so a renamed or
44
+ * duplicate-named app never causes a second create. A recorded uuid whose
45
+ * app no longer exists (not found) falls through to find-by-name, then create.
46
+ */
47
+ recordedConnectorAppId: string | null;
48
+ /**
49
+ * Called with the app's uuid as soon as it is created or found by name,
50
+ * before the token fetch, env write and deploy. The caller records and
51
+ * persists it there, so a later failure never leaves an app nothing points at.
52
+ */
53
+ recordAppId: (appId: string) => Promise<void>;
54
+ }
55
+ /**
56
+ * Finds or creates the one Coolify app that runs `cloudflared` for
57
+ * {@link EnsureCloudflaredConnectorInput.tunnelId}, writes its tunnel token as
58
+ * `TUNNEL_TOKEN` (a literal env var) when the token is new or changed, and
59
+ * deploys it when it was just created, its token changed, or it is not
60
+ * explicitly running (a missing status counts as not running, so a run that
61
+ * failed after the env write is repaired by the next one). `CLAUDE.md` rule 4.
62
+ *
63
+ * The token is a credential (`CLAUDE.md` rule 6): it goes to `bulkUpsertEnvs`
64
+ * and nowhere else. A failed write throws an error that names the app only,
65
+ * with no cause, because a Coolify error can echo the request body.
66
+ */
67
+ export declare function ensureCloudflaredConnector(input: EnsureCloudflaredConnectorInput): Promise<{
68
+ appId: string;
69
+ deployed: boolean;
70
+ }>;
@@ -0,0 +1,106 @@
1
+ import { isNotFoundError } from './executor.js';
2
+ /**
3
+ * #197: the pinned `cloudflared` image, the same release tag as
4
+ * `scripts/shared-ingress.mjs`'s and `calls-sfu.ts`'s own
5
+ * `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal.
6
+ */
7
+ export const CLOUDFLARED_IMAGE_REF = 'cloudflare/cloudflared:2026.9.3';
8
+ /**
9
+ * The inline Dockerfile a docker-image `cloudflared` app needs, copied from
10
+ * `scripts/shared-ingress.mjs`'s `cloudflaredDockerfile()`: the published
11
+ * image's own `CMD` is `version`, and Coolify's `start_command` is ignored
12
+ * for image-based applications, so the run command has to be stated in a
13
+ * Dockerfile's `CMD` instead. `TUNNEL_TOKEN` is never baked in here; it
14
+ * reaches the container only via `bulkUpsertEnvs`, as an environment
15
+ * variable `cloudflared` reads at boot.
16
+ */
17
+ export function cloudflaredDockerfile() {
18
+ return `FROM ${CLOUDFLARED_IMAGE_REF}\nCMD ["tunnel", "--no-autoupdate", "run"]\n`;
19
+ }
20
+ /**
21
+ * Finds or creates the one Coolify app that runs `cloudflared` for
22
+ * {@link EnsureCloudflaredConnectorInput.tunnelId}, writes its tunnel token as
23
+ * `TUNNEL_TOKEN` (a literal env var) when the token is new or changed, and
24
+ * deploys it when it was just created, its token changed, or it is not
25
+ * explicitly running (a missing status counts as not running, so a run that
26
+ * failed after the env write is repaired by the next one). `CLAUDE.md` rule 4.
27
+ *
28
+ * The token is a credential (`CLAUDE.md` rule 6): it goes to `bulkUpsertEnvs`
29
+ * and nowhere else. A failed write throws an error that names the app only,
30
+ * with no cause, because a Coolify error can echo the request body.
31
+ */
32
+ export async function ensureCloudflaredConnector(input) {
33
+ const { coolify, cloudflare } = input;
34
+ const liveStatus = async (uuid) => {
35
+ try {
36
+ return { found: true, status: (await coolify.getApplication(uuid)).status };
37
+ }
38
+ catch (error) {
39
+ if (isNotFoundError(error))
40
+ return { found: false };
41
+ throw error;
42
+ }
43
+ };
44
+ let appId = null;
45
+ let status;
46
+ let justCreated = false;
47
+ if (input.recordedConnectorAppId) {
48
+ const live = await liveStatus(input.recordedConnectorAppId);
49
+ if (live.found) {
50
+ appId = input.recordedConnectorAppId;
51
+ status = live.status;
52
+ }
53
+ }
54
+ if (!appId) {
55
+ const found = await coolify.findApplicationByName(input.appName, {
56
+ environmentIds: [input.environment.id],
57
+ });
58
+ if (found) {
59
+ const live = await liveStatus(found.uuid);
60
+ if (live.found) {
61
+ appId = found.uuid;
62
+ status = live.status;
63
+ }
64
+ }
65
+ }
66
+ if (!appId) {
67
+ const server = await input.resolveServer();
68
+ const created = await coolify.createDockerfileApplication({
69
+ project_uuid: input.projectUuid,
70
+ server_uuid: server.uuid,
71
+ environment_name: input.environment.name,
72
+ environment_uuid: input.environment.uuid,
73
+ dockerfile: cloudflaredDockerfile(),
74
+ name: input.appName,
75
+ description: 'cloudflared connector for this platform tunnel (#197)',
76
+ instant_deploy: false,
77
+ });
78
+ appId = created.uuid;
79
+ justCreated = true;
80
+ }
81
+ if (appId !== input.recordedConnectorAppId)
82
+ await input.recordAppId(appId);
83
+ const token = await cloudflare.getTunnelToken(input.tunnelId);
84
+ const tokenChanged = justCreated || (await tunnelTokenDiffers(coolify, appId, token));
85
+ if (tokenChanged) {
86
+ try {
87
+ await coolify.bulkUpsertEnvs(appId, [{ key: 'TUNNEL_TOKEN', value: token, literal: true }]);
88
+ }
89
+ catch {
90
+ throw new Error(`cloudflared connector ${input.appName}: writing TUNNEL_TOKEN failed`);
91
+ }
92
+ }
93
+ // Only a connector that is up or on its way up is left alone; a missing,
94
+ // stopped, crash-looping or degraded one is deployed.
95
+ const upOrStarting = Boolean(status?.startsWith('running') || status?.startsWith('starting'));
96
+ const deployed = justCreated || tokenChanged || !upOrStarting;
97
+ if (deployed) {
98
+ await coolify.deploy(appId);
99
+ }
100
+ return { appId, deployed };
101
+ }
102
+ /** Whether the app's live `TUNNEL_TOKEN` is missing or differs from the token just fetched. */
103
+ async function tunnelTokenDiffers(coolify, appId, token) {
104
+ const live = (await coolify.listEnvs(appId)).find((e) => e.key === 'TUNNEL_TOKEN' && !e.is_preview);
105
+ return !live || live.value !== token;
106
+ }
@@ -1,6 +1,8 @@
1
1
  import type { DeclaredOrg, OrgId } from '../org.js';
2
2
  import type { Plan, PlanStep, UnmanagedItem } from '../plan.js';
3
+ import { type AgoraHostReading } from './agora.js';
3
4
  import type { LiveResource } from './live.js';
5
+ import { type OrgPlatformReadiness } from './platform-org.js';
4
6
  /**
5
7
  * What a {@link StepExecutor} (see `plan-apply.ts`) needs to actually make a
6
8
  * step true, alongside the public {@link PlanStep} an operator reads.
@@ -60,12 +62,27 @@ export declare function toPlan(detail: PlanDetail): Plan;
60
62
  * by default unlike chat's own: {@link planCalls}, the org's own calls
61
63
  * Matrix stack (`org.ts`'s `CallsConfig.enabled`). #145 adds one more,
62
64
  * planned right after it and gated on the same flag: {@link planCallsService},
63
- * the calls service itself (`wtfalch/calls`'s `packages/service`). Everything
64
- * else in `live` that none of these fifteen singletons and however many
65
+ * the calls service itself (`wtfalch/calls`'s `packages/service`). #164 adds
66
+ * two more, unconditionally -- no `DeclaredOrg` field gates either, since
67
+ * every org's own Agora is always on: {@link planAgoraClient}, a fixed
68
+ * ZITADEL web client named `agora` (modeled on {@link planCmsClient}), and
69
+ * {@link planAgora}, the Agora itself as a Coolify app (modeled on
70
+ * {@link planSite}, minus the park branch -- foundry never deletes an
71
+ * Agora). #173 adds one more, gated on `org.hosting.enabled` like
72
+ * {@link planApp} itself: {@link planJobsRegistration}, registering the org's
73
+ * app with the platform's jobs scheduler, idempotent on the *recorded*
74
+ * resource id like {@link planOwner} rather than a live resource, since
75
+ * nothing in `live` can report a scheduler registration (no reader exists for
76
+ * it). Everything
77
+ * else in `live` that none of these eighteen singletons and however many
65
78
  * declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
66
79
  * guests, calls on the org's own domain, adopted/bespoke apps, the actual
67
80
  * Zitadel/Coolify/Cloudflare/Files/AI/cms shapes) is #20's "provision
68
81
  * Company Factory end to end", which wires real readers and executors
69
82
  * against this seam.
70
83
  */
71
- export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[], recordedIds?: Readonly<Record<string, string>>): PlanDetail;
84
+ export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[], recordedIds?: Readonly<Record<string, string>>, agoraHost?: AgoraHostReading, jobsAvailable?: boolean, platformReadiness?: OrgPlatformReadiness): PlanDetail;
85
+ /** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
86
+ export declare const AI_KEY_UNDELIVERED_SCOPE_KEY = "ai_key_undelivered";
87
+ /** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
88
+ export declare const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = "jobs_registration_undelivered";