@wtfalch/foundry 0.14.0 → 0.16.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 (42) hide show
  1. package/dist/bootstrap/declare.d.ts +2 -2
  2. package/dist/bootstrap/declare.js +6 -6
  3. package/dist/index.d.ts +4 -4
  4. package/dist/index.js +3 -3
  5. package/dist/migrations/0034_owners.sql +42 -0
  6. package/dist/migrations/0035_chat_on_by_default.sql +10 -0
  7. package/dist/org.d.ts +80 -17
  8. package/dist/org.js +221 -18
  9. package/dist/plan/agora.d.ts +19 -1
  10. package/dist/plan/agora.js +37 -2
  11. package/dist/plan/chat-matrix.d.ts +99 -0
  12. package/dist/plan/chat-matrix.js +194 -0
  13. package/dist/plan/diff.d.ts +5 -3
  14. package/dist/plan/diff.js +143 -83
  15. package/dist/plan/entry-ai-key.d.ts +58 -0
  16. package/dist/plan/entry-ai-key.js +209 -0
  17. package/dist/plan/entry-files-key.d.ts +82 -0
  18. package/dist/plan/entry-files-key.js +327 -0
  19. package/dist/plan/executor.d.ts +244 -16
  20. package/dist/plan/executor.js +860 -78
  21. package/dist/plan/hostnames.d.ts +8 -1
  22. package/dist/plan/hostnames.js +18 -1
  23. package/dist/plan/index.d.ts +2 -1
  24. package/dist/plan/index.js +2 -1
  25. package/dist/plan/plan-apply.d.ts +2 -0
  26. package/dist/plan/plan-apply.js +42 -2
  27. package/dist/plan/platform-diff.d.ts +8 -0
  28. package/dist/plan/platform-diff.js +8 -0
  29. package/dist/plan/platform-org.d.ts +17 -5
  30. package/dist/plan/platform-org.js +15 -7
  31. package/dist/plan/platform-providers.d.ts +38 -12
  32. package/dist/plan/platform-providers.js +99 -16
  33. package/dist/plan/proxy-labels.d.ts +42 -0
  34. package/dist/plan/proxy-labels.js +73 -0
  35. package/dist/plan/zitadel-machine-user.d.ts +5 -1
  36. package/dist/plan/zitadel-machine-user.js +5 -1
  37. package/dist/plan/zitadel-project-roles.d.ts +25 -0
  38. package/dist/plan/zitadel-project-roles.js +104 -0
  39. package/dist/store/postgres.js +4 -17
  40. package/dist/store/schema.d.ts +13 -45
  41. package/dist/store/schema.js +5 -8
  42. package/package.json +2 -2
@@ -1,3 +1,4 @@
1
+ import { chatUpstreams } from './chat-matrix.js';
1
2
  /**
2
3
  * #164: what both the planner (`diff.ts`) and the executor (`executor.ts`)
3
4
  * need to agree on for an org's Agora -- the hostname, the values the host
@@ -11,6 +12,31 @@
11
12
  * (recorded by `executor.ts`'s `zitadelApp`).
12
13
  */
13
14
  export const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
15
+ /**
16
+ * The org's ZITADEL messaging project: the audience chat-service requires in
17
+ * a token's `aud`, and Agora's `messagingProjectId`. `executor.ts`'s
18
+ * `coolifyChat` finds or creates it (`<slug>-messaging`) and records its id
19
+ * here; a host value for the same id wins when it supplies one.
20
+ */
21
+ export const ZITADEL_MESSAGING_PROJECT_SCOPE_KEY = 'zitadel_messaging_project';
22
+ /**
23
+ * Recorded by `coolifyChat` last, once chat's three apps carry their fixed
24
+ * internal names: the chat service's address. `desiredAgoraEnv` waits for it
25
+ * (equal to the address the current slug gives), so Agora is never handed an
26
+ * address that does not resolve yet.
27
+ */
28
+ export const CHAT_SERVICE_URL_SCOPE_KEY = 'coolify_chat_service_url';
29
+ /** Whether the recorded marker says chat's apps carry the names the org's current slug gives them. */
30
+ export function chatHasUpstreams(org, recorded) {
31
+ return recorded[CHAT_SERVICE_URL_SCOPE_KEY] === chatUpstreams(org.slug).chat;
32
+ }
33
+ /** `host` with the recorded messaging project filled in when the host supplies none. */
34
+ export function withRecordedMessagingProject(host, recorded) {
35
+ return {
36
+ ...host,
37
+ messagingProjectId: host.messagingProjectId ?? recorded[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY] ?? null,
38
+ };
39
+ }
14
40
  export const AGORA_CLIENT_ID_SCOPE_KEY = 'agora_client_id';
15
41
  /**
16
42
  * The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
@@ -181,7 +207,8 @@ export function callsServiceHasTokenEnv(host, recorded) {
181
207
  * not `host`: present only when calls are on and this org's calls service
182
208
  * app carries its internal name ({@link callsServiceHasName}).
183
209
  */
184
- export function desiredAgoraEnv(org, recorded, host) {
210
+ export function desiredAgoraEnv(org, recorded, rawHost) {
211
+ const host = withRecordedMessagingProject(rawHost, recorded);
185
212
  const env = { AGORA_CANONICAL_HOST: agoraHostnameFor(org) };
186
213
  const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
187
214
  const clientId = recorded[AGORA_CLIENT_ID_SCOPE_KEY];
@@ -200,7 +227,7 @@ export function desiredAgoraEnv(org, recorded, host) {
200
227
  organisationId: zitadelOrgId,
201
228
  mailProjectId: host.mailProjectId,
202
229
  messagingProjectId: host.messagingProjectId,
203
- // Chat is not in v1: Agora hides all chat UI unless this is true.
230
+ // Agora hides all chat UI unless this is true.
204
231
  chat: { enabled: org.chat.enabled },
205
232
  // Agora draws call buttons only when this is true.
206
233
  calls: { enabled: org.calls.enabled },
@@ -214,6 +241,14 @@ export function desiredAgoraEnv(org, recorded, host) {
214
241
  if (host.filesUpstream) {
215
242
  env.AGORA_FILES_UPSTREAM = host.filesUpstream;
216
243
  }
244
+ // Chat's three upstreams go together: the chat service alone cannot sign a
245
+ // person in (MAS) or load a room (Synapse).
246
+ if (org.chat.enabled && chatHasUpstreams(org, recorded)) {
247
+ const upstreams = chatUpstreams(org.slug);
248
+ env.AGORA_CHAT_UPSTREAM = upstreams.chat;
249
+ env.AGORA_MATRIX_UPSTREAM = upstreams.matrix;
250
+ env.AGORA_MAS_UPSTREAM = upstreams.mas;
251
+ }
217
252
  if (org.calls.enabled && callsServiceHasName(org, recorded)) {
218
253
  env.AGORA_CALLS_UPSTREAM = `http://${callsServiceInternalName(org)}:${CALLS_SERVICE_PORT}`;
219
254
  }
@@ -0,0 +1,99 @@
1
+ import { type CallsDbConnection, type CallsMatrixSecrets, mintCallsMatrixSecrets } from './calls-matrix.js';
2
+ /**
3
+ * The org's chat stack: pure config generation for Synapse (with the
4
+ * `wtfalch_chat_gate` module), matrix-authentication-service (MAS) and the
5
+ * chat service, deployed by `executor.ts`'s `coolifyChat`. It follows
6
+ * `calls-matrix.ts`: three Coolify-managed Postgres databases (Synapse, MAS,
7
+ * chat), one config file per Matrix app, and internal Docker-network
8
+ * addresses only.
9
+ *
10
+ * NO PUBLIC HOSTNAME OF ITS OWN
11
+ * ------------------------------
12
+ * The org's Agora is the one public origin. Its Caddy proxies `/_chat/*`,
13
+ * the Matrix client routes and MAS's compat login to the three internal
14
+ * addresses below (`AGORA_CHAT_UPSTREAM`, `AGORA_MATRIX_UPSTREAM`,
15
+ * `AGORA_MAS_UPSTREAM`, `agora/apps/agora/Caddyfile`), so neither tunnel
16
+ * routes nor DNS are needed. The chat service mints Matrix sessions through
17
+ * MAS's admin API over the internal address and hands clients the Agora URL
18
+ * as their homeserver (`CHAT_HOMESERVER_URL`).
19
+ *
20
+ * Secrets are the same six values the calls stack mints
21
+ * ({@link mintCallsMatrixSecrets}); they live as envs on the chat MAS app,
22
+ * the only memory across a re-run (`CHAT_SECRET_ENV_KEYS` in `executor.ts`).
23
+ */
24
+ export type ChatMatrixSecrets = CallsMatrixSecrets;
25
+ export declare const mintChatMatrixSecrets: typeof mintCallsMatrixSecrets;
26
+ /** The chat repo: the Synapse module and the chat service are both built from it. */
27
+ export declare const CHAT_REPO = "wtfalch/chat";
28
+ /** Synapse plus the `wtfalch_chat_gate` module; built from the repo root. */
29
+ export declare const CHAT_SYNAPSE_DOCKERFILE = "apps/chat-service/synapse-module/Dockerfile";
30
+ /** The chat service, built from the repo root (that Dockerfile's own top comment). */
31
+ export declare const CHAT_SERVICE_DOCKERFILE = "apps/chat-service/Dockerfile";
32
+ export declare const CHAT_SYNAPSE_PORT = "8008";
33
+ export declare const CHAT_MAS_PORT = "8080";
34
+ export declare const CHAT_SERVICE_PORT = "8080";
35
+ /** Fixed internal names, so the three apps and Agora address each other without guessing Coolify's own. */
36
+ export declare function chatInternalNames(orgSlug: string): {
37
+ synapse: string;
38
+ mas: string;
39
+ service: string;
40
+ synapseDb: string;
41
+ masDb: string;
42
+ serviceDb: string;
43
+ };
44
+ /** The three addresses Agora proxies to, as `http://<internal name>:<port>`. */
45
+ export declare function chatUpstreams(orgSlug: string): {
46
+ chat: string;
47
+ matrix: string;
48
+ mas: string;
49
+ };
50
+ /**
51
+ * Never resolved by DNS and never federates; the identifier Synapse, MAS and
52
+ * the chat service agree on. A level-1 org's chat lives on this name until a
53
+ * re-key onto its domain (not built). `.internal` never resolves, so it can
54
+ * not collide with a hostname some org owns.
55
+ */
56
+ export declare function chatServerName(orgSlug: string): string;
57
+ /** The Matrix user the chat service acts as (`chat/apps/chat-service/src/workspace.ts`). */
58
+ export declare function chatAdminUser(orgSlug: string): string;
59
+ export interface ChatSynapseHomeserverInput {
60
+ orgSlug: string;
61
+ db: CallsDbConnection;
62
+ masHost: string;
63
+ masSharedSecret: string;
64
+ }
65
+ /** `homeserver.yaml`: federation and presence off, MAS for auth, the chat gate module on. */
66
+ export declare function chatSynapseHomeserverYaml(input: ChatSynapseHomeserverInput): string;
67
+ export interface ChatMasConfigInput {
68
+ orgSlug: string;
69
+ /** The org's Agora hostname: MAS's `public_base`, the origin clients reach it on. */
70
+ publicHost: string;
71
+ db: CallsDbConnection;
72
+ synapseHost: string;
73
+ sharedSecret: string;
74
+ clientId: string;
75
+ clientSecret: string;
76
+ encryptionKey: string;
77
+ rsaKeyPem: string;
78
+ ecKeyPem: string;
79
+ }
80
+ /** MAS `config.yaml`: password login and registration off; the chat service is the one client, with admin. */
81
+ export declare function chatMasConfigYaml(input: ChatMasConfigInput): string;
82
+ export interface ChatServiceEnvInput {
83
+ orgSlug: string;
84
+ /** The org's display name: the workspace space's name. */
85
+ companyName: string;
86
+ issuer: string;
87
+ /** The org's ZITADEL messaging project id, required in a token's `aud`. */
88
+ audience: string;
89
+ zitadelOrgId: string;
90
+ agoraHost: string;
91
+ masClientId: string;
92
+ masClientSecret: string;
93
+ databaseUrl: string;
94
+ }
95
+ /** Every variable `chat/apps/chat-service/src/config.ts` requires, plus the internal Synapse address and port. */
96
+ export declare function chatServiceEnvs(input: ChatServiceEnvInput): Array<{
97
+ key: string;
98
+ value: string;
99
+ }>;
@@ -0,0 +1,194 @@
1
+ import { mintCallsMatrixSecrets, } from './calls-matrix.js';
2
+ export const mintChatMatrixSecrets = mintCallsMatrixSecrets;
3
+ /** The chat repo: the Synapse module and the chat service are both built from it. */
4
+ export const CHAT_REPO = 'wtfalch/chat';
5
+ /** Synapse plus the `wtfalch_chat_gate` module; built from the repo root. */
6
+ export const CHAT_SYNAPSE_DOCKERFILE = 'apps/chat-service/synapse-module/Dockerfile';
7
+ /** The chat service, built from the repo root (that Dockerfile's own top comment). */
8
+ export const CHAT_SERVICE_DOCKERFILE = 'apps/chat-service/Dockerfile';
9
+ export const CHAT_SYNAPSE_PORT = '8008';
10
+ export const CHAT_MAS_PORT = '8080';
11
+ export const CHAT_SERVICE_PORT = '8080';
12
+ /** Fixed internal names, so the three apps and Agora address each other without guessing Coolify's own. */
13
+ export function chatInternalNames(orgSlug) {
14
+ return {
15
+ synapse: `${orgSlug}-chat-synapse`,
16
+ mas: `${orgSlug}-chat-mas`,
17
+ service: `${orgSlug}-chat-service`,
18
+ synapseDb: `${orgSlug}-chat-synapse-db`,
19
+ masDb: `${orgSlug}-chat-mas-db`,
20
+ serviceDb: `${orgSlug}-chat-service-db`,
21
+ };
22
+ }
23
+ /** The three addresses Agora proxies to, as `http://<internal name>:<port>`. */
24
+ export function chatUpstreams(orgSlug) {
25
+ const names = chatInternalNames(orgSlug);
26
+ return {
27
+ chat: `http://${names.service}:${CHAT_SERVICE_PORT}`,
28
+ matrix: `http://${names.synapse}:${CHAT_SYNAPSE_PORT}`,
29
+ mas: `http://${names.mas}:${CHAT_MAS_PORT}`,
30
+ };
31
+ }
32
+ /**
33
+ * Never resolved by DNS and never federates; the identifier Synapse, MAS and
34
+ * the chat service agree on. A level-1 org's chat lives on this name until a
35
+ * re-key onto its domain (not built). `.internal` never resolves, so it can
36
+ * not collide with a hostname some org owns.
37
+ */
38
+ export function chatServerName(orgSlug) {
39
+ return `chat-${orgSlug}.internal`;
40
+ }
41
+ /** The Matrix user the chat service acts as (`chat/apps/chat-service/src/workspace.ts`). */
42
+ export function chatAdminUser(orgSlug) {
43
+ return `@chat-admin:${chatServerName(orgSlug)}`;
44
+ }
45
+ const q = (value) => JSON.stringify(value);
46
+ /** `homeserver.yaml`: federation and presence off, MAS for auth, the chat gate module on. */
47
+ export function chatSynapseHomeserverYaml(input) {
48
+ return [
49
+ '# Generated by @wtfalch/foundry (executor.ts, coolify:chat). Do not edit',
50
+ '# by hand; re-run apply instead -- it will not rotate secrets that exist.',
51
+ '',
52
+ `server_name: ${q(chatServerName(input.orgSlug))}`,
53
+ 'pid_file: /data/homeserver.pid',
54
+ 'report_stats: false',
55
+ '',
56
+ 'listeners:',
57
+ ` - port: ${CHAT_SYNAPSE_PORT}`,
58
+ ' type: http',
59
+ ' tls: false',
60
+ ' bind_addresses: ["0.0.0.0"]',
61
+ ' x_forwarded: true',
62
+ ' resources:',
63
+ ' - names: [client]',
64
+ ' compress: false',
65
+ '',
66
+ 'database:',
67
+ ' name: psycopg2',
68
+ ' args:',
69
+ ` user: ${q(input.db.user)}`,
70
+ ` password: ${q(input.db.password)}`,
71
+ ` dbname: ${q(input.db.database)}`,
72
+ ` host: ${q(input.db.host)}`,
73
+ ` port: ${input.db.port}`,
74
+ ' cp_min: 5',
75
+ ' cp_max: 10',
76
+ '',
77
+ '# An empty list is the recommended way of disabling federation.',
78
+ 'federation_domain_whitelist: []',
79
+ '',
80
+ '# Presence is out of scope for v1 (foundry#41).',
81
+ 'presence:',
82
+ ' enabled: false',
83
+ '',
84
+ 'enable_authenticated_media: true',
85
+ 'max_upload_size: "50M"',
86
+ 'encryption_enabled_by_default_for_room_type: "off"',
87
+ 'auto_join_rooms: []',
88
+ '',
89
+ 'media_store_path: /data/media_store',
90
+ 'signing_key_path: /data/signing.key',
91
+ '',
92
+ 'matrix_authentication_service:',
93
+ ' enabled: true',
94
+ ` endpoint: ${q(`http://${input.masHost}:${CHAT_MAS_PORT}/`)}`,
95
+ ` secret: ${q(input.masSharedSecret)}`,
96
+ '',
97
+ '# Gates channel creation, invites, joins and encryption (chat repo, MSG-007).',
98
+ 'modules:',
99
+ ' - module: wtfalch_chat_gate.ChatGate',
100
+ ' config:',
101
+ ` admin_user: ${q(chatAdminUser(input.orgSlug))}`,
102
+ ' allow_encrypted_channels: false',
103
+ '',
104
+ ].join('\n');
105
+ }
106
+ /** MAS `config.yaml`: password login and registration off; the chat service is the one client, with admin. */
107
+ export function chatMasConfigYaml(input) {
108
+ const indentKey = (pem, spaces) => pem
109
+ .trimEnd()
110
+ .split('\n')
111
+ .map((line) => `${' '.repeat(spaces)}${line}`)
112
+ .join('\n');
113
+ return [
114
+ '# Generated by @wtfalch/foundry (executor.ts, coolify:chat). Do not edit by hand.',
115
+ '',
116
+ 'http:',
117
+ ' listeners:',
118
+ ' - name: web',
119
+ ' resources:',
120
+ ' - name: discovery',
121
+ ' - name: human',
122
+ ' - name: oauth',
123
+ ' - name: compat',
124
+ ' - name: graphql',
125
+ ' - name: assets',
126
+ ' - name: adminapi',
127
+ ' - name: health',
128
+ ' binds:',
129
+ ` - address: "[::]:${CHAT_MAS_PORT}"`,
130
+ ' proxy_protocol: false',
131
+ ` public_base: ${q(`https://${input.publicHost}/`)}`,
132
+ ' trusted_proxies:',
133
+ ' - 0.0.0.0/0',
134
+ '',
135
+ 'database:',
136
+ ` host: ${q(input.db.host)}`,
137
+ ` port: ${input.db.port}`,
138
+ ` username: ${q(input.db.user)}`,
139
+ ` password: ${q(input.db.password)}`,
140
+ ` database: ${q(input.db.database)}`,
141
+ ' ssl_mode: disable',
142
+ '',
143
+ 'matrix:',
144
+ ' kind: synapse',
145
+ ` homeserver: ${q(chatServerName(input.orgSlug))}`,
146
+ ` endpoint: ${q(`http://${input.synapseHost}:${CHAT_SYNAPSE_PORT}`)}`,
147
+ ` secret: ${q(input.sharedSecret)}`,
148
+ '',
149
+ '# The chat service is the only way in.',
150
+ 'passwords:',
151
+ ' enabled: false',
152
+ '',
153
+ 'account:',
154
+ ' password_registration_enabled: false',
155
+ '',
156
+ 'clients:',
157
+ ` - client_id: ${q(input.clientId)}`,
158
+ ' client_auth_method: client_secret_basic',
159
+ ` client_secret: ${q(input.clientSecret)}`,
160
+ '',
161
+ 'policy:',
162
+ ' data:',
163
+ ' admin_clients:',
164
+ ` - ${q(input.clientId)}`,
165
+ '',
166
+ 'secrets:',
167
+ ` encryption: ${input.encryptionKey}`,
168
+ ' keys:',
169
+ ' - key: |',
170
+ indentKey(input.rsaKeyPem, 8),
171
+ ' - key: |',
172
+ indentKey(input.ecKeyPem, 8),
173
+ '',
174
+ ].join('\n');
175
+ }
176
+ /** Every variable `chat/apps/chat-service/src/config.ts` requires, plus the internal Synapse address and port. */
177
+ export function chatServiceEnvs(input) {
178
+ const upstreams = chatUpstreams(input.orgSlug);
179
+ return [
180
+ { key: 'CHAT_ISSUER', value: input.issuer },
181
+ { key: 'CHAT_AUDIENCE', value: input.audience },
182
+ { key: 'CHAT_ORGANISATION_ID', value: input.zitadelOrgId },
183
+ { key: 'CHAT_HOMESERVER_URL', value: `https://${input.agoraHost}` },
184
+ { key: 'CHAT_INTERNAL_HOMESERVER_URL', value: upstreams.matrix },
185
+ { key: 'CHAT_SERVER_NAME', value: chatServerName(input.orgSlug) },
186
+ { key: 'CHAT_COMPANY_NAME', value: input.companyName },
187
+ { key: 'MAS_URL', value: upstreams.mas },
188
+ { key: 'MAS_CLIENT_ID', value: input.masClientId },
189
+ { key: 'MAS_CLIENT_SECRET', value: input.masClientSecret },
190
+ { key: 'DATABASE_URL', value: input.databaseUrl },
191
+ { key: 'CHAT_ALLOWED_ORIGINS', value: `https://${input.agoraHost}` },
192
+ { key: 'PORT', value: CHAT_SERVICE_PORT },
193
+ ];
194
+ }
@@ -36,6 +36,8 @@ export interface PlanOrgOptions {
36
36
  loginHostBindings?: boolean;
37
37
  /** F5: the executor was built with a `broker` port. */
38
38
  broker?: boolean;
39
+ /** F6a, F6b: the clock `planEntryAiKeys` and `planFilesKeys` read for the renewal window. Default `Date.now`. */
40
+ now?: () => number;
39
41
  }
40
42
  export interface PlanDetail {
41
43
  orgId: OrgId;
@@ -67,7 +69,7 @@ export declare function toPlan(detail: PlanDetail): Plan;
67
69
  * `planAiOrganisation`, each planned before its own system's limit step
68
70
  * ({@link planStorage}, {@link planAiBudget}) so an org has somewhere for a
69
71
  * limit to land, and AI use is switched on, before either limit is ever set.
70
- * `planOwner` below adds one more singleton, in a different shape from the
72
+ * `planOwners` below adds one more singleton, in a different shape from the
71
73
  * rest: the org's owner, invited by ZITADEL email, idempotent on the
72
74
  * *recorded* resource id (`recordedIds`, sourced from
73
75
  * `AppliedState.serviceIds`) rather than a live resource, since a human user
@@ -84,9 +86,9 @@ export declare function toPlan(detail: PlanDetail): Plan;
84
86
  * Agora). #173 adds one more, gated on `org.hosting.enabled` like
85
87
  * {@link planApp} itself: {@link planJobsRegistration}, registering the org's
86
88
  * app with the platform's jobs scheduler, idempotent on the *recorded*
87
- * resource id like {@link planOwner} rather than a live resource, since
89
+ * resource id like {@link planOwners} rather than a live resource, since
88
90
  * nothing in `live` can report a scheduler registration (no reader exists for
89
- * it). #267 adds one more, right after {@link planOwner} and before every
91
+ * it). #267 adds one more, right after {@link planOwners} and before every
90
92
  * app-creating step below: {@link planLoginHost}, the org's own company
91
93
  * login host -- also idempotent on the *recorded* resource id, since
92
94
  * `live` has no notion of a ZITADEL trusted domain or a login binding