@wtfalch/foundry 0.16.16 → 0.17.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 (59) hide show
  1. package/dist/apply.d.ts +22 -1
  2. package/dist/bootstrap/bootstrap.d.ts +6 -1
  3. package/dist/bootstrap/bootstrap.js +26 -3
  4. package/dist/bootstrap/declare.d.ts +11 -1
  5. package/dist/bootstrap/declare.js +12 -2
  6. package/dist/index.d.ts +9 -4
  7. package/dist/index.js +7 -0
  8. package/dist/migrations/0038_calls_version_pin.sql +8 -0
  9. package/dist/migrations/0039_destroy_record.sql +7 -0
  10. package/dist/migrations/0040_company_included_uuid.sql +72 -0
  11. package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
  12. package/dist/migrations/0042_mutation_leases.sql +12 -0
  13. package/dist/org.d.ts +54 -0
  14. package/dist/org.js +43 -1
  15. package/dist/plan/adopt.d.ts +76 -0
  16. package/dist/plan/adopt.js +184 -0
  17. package/dist/plan/agora.d.ts +21 -0
  18. package/dist/plan/agora.js +37 -0
  19. package/dist/plan/broker-entries.d.ts +9 -1
  20. package/dist/plan/broker-entries.js +13 -5
  21. package/dist/plan/chat-matrix.d.ts +1 -1
  22. package/dist/plan/chat-matrix.js +11 -3
  23. package/dist/plan/core-files.d.ts +25 -0
  24. package/dist/plan/core-files.js +25 -0
  25. package/dist/plan/diff.d.ts +11 -3
  26. package/dist/plan/diff.js +27 -62
  27. package/dist/plan/entry-ai-key.d.ts +3 -3
  28. package/dist/plan/entry-files-key.d.ts +3 -3
  29. package/dist/plan/executor.d.ts +57 -23
  30. package/dist/plan/executor.js +134 -50
  31. package/dist/plan/index.d.ts +4 -0
  32. package/dist/plan/index.js +2 -0
  33. package/dist/plan/live.d.ts +5 -0
  34. package/dist/plan/login-host.d.ts +1 -1
  35. package/dist/plan/login-host.js +6 -5
  36. package/dist/plan/plan-apply.d.ts +139 -6
  37. package/dist/plan/plan-apply.js +546 -76
  38. package/dist/plan/platform-diff.d.ts +14 -0
  39. package/dist/plan/platform-diff.js +16 -1
  40. package/dist/plan/platform-org.d.ts +16 -0
  41. package/dist/plan/platform-org.js +17 -1
  42. package/dist/plan/platform-providers.d.ts +1 -1
  43. package/dist/plan/platform-providers.js +73 -11
  44. package/dist/plan/providers-readers.d.ts +2 -0
  45. package/dist/plan/providers-readers.js +4 -0
  46. package/dist/plan/rollout.d.ts +52 -0
  47. package/dist/plan/rollout.js +37 -0
  48. package/dist/plan/step-system.d.ts +22 -0
  49. package/dist/plan/step-system.js +37 -0
  50. package/dist/plan.d.ts +3 -0
  51. package/dist/state.d.ts +13 -0
  52. package/dist/store/audit.js +15 -1
  53. package/dist/store/postgres.d.ts +1 -1
  54. package/dist/store/postgres.js +127 -14
  55. package/dist/store/schema.d.ts +134 -5
  56. package/dist/store/schema.js +12 -1
  57. package/dist/store.d.ts +47 -4
  58. package/dist/store.js +9 -1
  59. package/package.json +1 -1
@@ -0,0 +1,76 @@
1
+ import type { DeclaredOrg } from '../org.js';
2
+ import type { Plan } from '../plan.js';
3
+ import type { LiveResource } from './live.js';
4
+ /**
5
+ * Adopt a live app and its database in place (archon#271). The caller names
6
+ * what already runs in Coolify; foundry reads it, checks it is what it would
7
+ * have created for the entry, and records the ids so the next plan finds them
8
+ * instead of planning a create. Nothing here calls Coolify to change anything.
9
+ *
10
+ * No redeploy happens at adopt. The next normal plan may still propose
11
+ * updates to the adopted app (log volume, env push, hostnames, labels) because
12
+ * foundry's applied markers are absent; applying those redeploys the app. The
13
+ * host must show that plan to the operator before it is applied.
14
+ */
15
+ export interface AdoptInput {
16
+ /** The declared hosting entry (`HostingEntry.key`) that takes the app over. It must have `database: true`. */
17
+ entryKey: string;
18
+ /** The Coolify application uuid. */
19
+ applicationUuid: string;
20
+ /** The Coolify Postgres database uuid. */
21
+ databaseUuid: string;
22
+ /** The Coolify project both live in. Optional when the org already records one. */
23
+ projectUuid?: string;
24
+ }
25
+ /** What the adopted resources looked like when adopted: the reader's output, never a secret. */
26
+ export interface AdoptSnapshot {
27
+ adoptedAt: string;
28
+ projectUuid: string;
29
+ /** True when adopt recorded `coolify_project` itself, so release takes it out again. */
30
+ projectRecorded: boolean;
31
+ application: {
32
+ uuid: string;
33
+ config: Record<string, unknown>;
34
+ };
35
+ database: {
36
+ uuid: string;
37
+ config: Record<string, unknown>;
38
+ };
39
+ }
40
+ /** The flat scope key (no `:`) holding an entry's {@link AdoptSnapshot} as JSON. Its presence means "adopted". */
41
+ export declare function adoptedScopeKey(entryKey: string): string;
42
+ /** `null` when absent, or when it is not a snapshot (callers that delete must treat the latter as an error: see {@link adoptedKeysUnreadable}). */
43
+ export declare function parseAdoptSnapshot(raw: string | undefined): AdoptSnapshot | null;
44
+ /** The `adopted.<entry>` keys of `serviceIds` whose value is not a readable snapshot. */
45
+ export declare function adoptedKeysUnreadable(serviceIds: Readonly<Record<string, string>>): string[];
46
+ export declare function snapshotConfig(config: Record<string, unknown>): Record<string, unknown>;
47
+ export declare class AdoptRefusedError extends Error {
48
+ readonly reasons: string[];
49
+ constructor(reasons: string[]);
50
+ }
51
+ export interface AdoptPlan {
52
+ plan: Plan;
53
+ /** Empty when the live resources match the entry. A non-empty list refuses `adopt` and the plan has no steps. */
54
+ mismatches: string[];
55
+ /** What `adopt` would record; absent with mismatches. */
56
+ snapshot?: AdoptSnapshot;
57
+ }
58
+ /** A live app or database in the project that this org has not recorded, for Archon's "Live, not theirs". */
59
+ export interface AdoptCandidate {
60
+ kind: 'application' | 'database';
61
+ uuid: string;
62
+ name: string;
63
+ status?: string;
64
+ /** True when its name is the one foundry would have given it for the entry. */
65
+ matchesName: boolean;
66
+ }
67
+ /** Live applications and databases in the project that no recorded id of this org names. */
68
+ export declare function adoptCandidates(org: DeclaredOrg, entryKey: string, recordedIds: Readonly<Record<string, string>>, live: readonly LiveResource[]): AdoptCandidate[];
69
+ /**
70
+ * Checks `input` against the declaration, the recorded ids and the live
71
+ * resources of the project, and builds the plan steps. `live` is the reader's
72
+ * output for the project (`null` when the reader cannot read a project).
73
+ */
74
+ export declare function planAdoptFrom(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, input: AdoptInput, live: readonly LiveResource[] | null, now: Date,
75
+ /** What another org already records among the three uuids, one reason each (`FoundryPlanApply.computeAdopt`). */
76
+ claimedElsewhere?: readonly string[]): AdoptPlan;
@@ -0,0 +1,184 @@
1
+ import { appNameFor, appStepKey } from './platform-diff.js';
2
+ import { withStepSystem } from './step-system.js';
3
+ /** The flat scope key (no `:`) holding an entry's {@link AdoptSnapshot} as JSON. Its presence means "adopted". */
4
+ export function adoptedScopeKey(entryKey) {
5
+ return `adopted.${entryKey}`;
6
+ }
7
+ /** `null` when absent, or when it is not a snapshot (callers that delete must treat the latter as an error: see {@link adoptedKeysUnreadable}). */
8
+ export function parseAdoptSnapshot(raw) {
9
+ if (!raw)
10
+ return null;
11
+ try {
12
+ const v = JSON.parse(raw);
13
+ return typeof v?.application?.uuid === 'string' && typeof v?.database?.uuid === 'string'
14
+ ? v
15
+ : null;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ /** The `adopted.<entry>` keys of `serviceIds` whose value is not a readable snapshot. */
22
+ export function adoptedKeysUnreadable(serviceIds) {
23
+ return Object.entries(serviceIds)
24
+ .filter(([k, v]) => k.startsWith('adopted.') && parseAdoptSnapshot(v) === null)
25
+ .map(([k]) => k);
26
+ }
27
+ /** The only reader fields a snapshot keeps, and only as plain values: never an env value, password or URL with a credential. */
28
+ const SNAPSHOT_FIELDS = [
29
+ 'name',
30
+ 'status',
31
+ 'fqdn',
32
+ 'image',
33
+ 'docker_registry_image_name',
34
+ 'git_repository',
35
+ 'git_branch',
36
+ 'is_auto_deploy_enabled',
37
+ 'environment_id',
38
+ 'server',
39
+ 'server_uuid',
40
+ 'destination',
41
+ 'destination_uuid',
42
+ ];
43
+ export function snapshotConfig(config) {
44
+ const out = {};
45
+ for (const field of SNAPSHOT_FIELDS) {
46
+ const v = config[field];
47
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
48
+ out[field] = v;
49
+ }
50
+ return out;
51
+ }
52
+ export class AdoptRefusedError extends Error {
53
+ reasons;
54
+ constructor(reasons) {
55
+ super(`adopt refused: ${reasons.join('; ')}`);
56
+ this.reasons = reasons;
57
+ this.name = 'AdoptRefusedError';
58
+ }
59
+ }
60
+ function databaseNameFor(org) {
61
+ return `${org.slug}-db`;
62
+ }
63
+ function nameOf(r) {
64
+ return typeof r.config.name === 'string' ? r.config.name : '';
65
+ }
66
+ /** Live applications and databases in the project that no recorded id of this org names. */
67
+ export function adoptCandidates(org, entryKey, recordedIds, live) {
68
+ const entry = org.hosting.entries.find((e) => e.key === entryKey);
69
+ const taken = new Set(Object.values(recordedIds));
70
+ const out = [];
71
+ for (const r of live) {
72
+ if ((r.kind !== 'application' && r.kind !== 'database') || taken.has(r.key))
73
+ continue;
74
+ const want = r.kind === 'application' ? (entry ? appNameFor(org, entry) : null) : databaseNameFor(org);
75
+ out.push({
76
+ kind: r.kind,
77
+ uuid: r.key,
78
+ name: nameOf(r),
79
+ ...(typeof r.config.status === 'string' ? { status: r.config.status } : {}),
80
+ matchesName: want !== null && nameOf(r) === want,
81
+ });
82
+ }
83
+ return out;
84
+ }
85
+ /**
86
+ * Checks `input` against the declaration, the recorded ids and the live
87
+ * resources of the project, and builds the plan steps. `live` is the reader's
88
+ * output for the project (`null` when the reader cannot read a project).
89
+ */
90
+ export function planAdoptFrom(org, recordedIds, input, live, now,
91
+ /** What another org already records among the three uuids, one reason each (`FoundryPlanApply.computeAdopt`). */
92
+ claimedElsewhere = []) {
93
+ const mismatches = [...claimedElsewhere];
94
+ const entry = org.hosting.enabled
95
+ ? org.hosting.entries.find((e) => e.key === input.entryKey)
96
+ : undefined;
97
+ if (!entry)
98
+ mismatches.push(`${org.slug} declares no hosting entry ${input.entryKey}`);
99
+ else if (!entry.database) {
100
+ mismatches.push(`entry ${input.entryKey} declares no database, so there is none to adopt`);
101
+ }
102
+ const appKey = `coolify:app:${appStepKey(org, input.entryKey)}`;
103
+ const dbKey = `coolify:database:${org.id}`;
104
+ if (recordedIds[appKey] || recordedIds[dbKey] || recordedIds[adoptedScopeKey(input.entryKey)]) {
105
+ mismatches.push(`entry ${input.entryKey} is already applied (ids are recorded); release it first or let apply manage it`);
106
+ }
107
+ const recordedProject = recordedIds.coolify_project;
108
+ const projectUuid = input.projectUuid ?? recordedProject;
109
+ if (!projectUuid)
110
+ mismatches.push('no Coolify project: name projectUuid');
111
+ else if (recordedProject && recordedProject !== projectUuid) {
112
+ mismatches.push(`project ${projectUuid} is not the org's recorded project ${recordedProject}`);
113
+ }
114
+ let app;
115
+ let db;
116
+ if (projectUuid && live === null) {
117
+ mismatches.push('the Coolify reader cannot read a project, so nothing can be verified');
118
+ }
119
+ else if (projectUuid && live) {
120
+ app = live.find((r) => r.kind === 'application' && r.key === input.applicationUuid);
121
+ db = live.find((r) => r.kind === 'database' && r.key === input.databaseUuid);
122
+ if (!app) {
123
+ mismatches.push(`application ${input.applicationUuid} is not in project ${projectUuid}`);
124
+ }
125
+ else if (entry && nameOf(app) !== appNameFor(org, entry)) {
126
+ mismatches.push(`application is named ${nameOf(app)}, foundry would name it ${appNameFor(org, entry)}`);
127
+ }
128
+ if (!db) {
129
+ mismatches.push(`database ${input.databaseUuid} is not in project ${projectUuid}`);
130
+ }
131
+ else if (nameOf(db) !== databaseNameFor(org)) {
132
+ mismatches.push(`database is named ${nameOf(db)}, foundry would name it ${databaseNameFor(org)}`);
133
+ }
134
+ }
135
+ if (mismatches.length > 0 || !app || !db || !projectUuid) {
136
+ return {
137
+ plan: {
138
+ orgId: org.id,
139
+ steps: [],
140
+ unmanaged: mismatches.map((description) => ({ description, key: 'adopt:mismatch' })),
141
+ },
142
+ mismatches,
143
+ };
144
+ }
145
+ const drafts = [
146
+ {
147
+ action: {
148
+ system: 'coolify',
149
+ kind: 'adopt-app',
150
+ key: appStepKey(org, input.entryKey),
151
+ desired: { applicationUuid: input.applicationUuid },
152
+ },
153
+ step: {
154
+ description: `adopt ${org.slug}'s ${input.entryKey} app (Coolify ${input.applicationUuid}) in place: record it, no redeploy`,
155
+ destroys: false,
156
+ migrates: false,
157
+ },
158
+ },
159
+ {
160
+ action: {
161
+ system: 'coolify',
162
+ kind: 'adopt-database',
163
+ key: org.id,
164
+ desired: { databaseUuid: input.databaseUuid },
165
+ },
166
+ step: {
167
+ description: `adopt ${org.slug}'s database (Coolify ${input.databaseUuid}) in place: record it, no restart`,
168
+ destroys: false,
169
+ migrates: false,
170
+ },
171
+ },
172
+ ];
173
+ return {
174
+ plan: { orgId: org.id, steps: drafts.map((d) => withStepSystem(d).step), unmanaged: [] },
175
+ mismatches,
176
+ snapshot: {
177
+ adoptedAt: now.toISOString(),
178
+ projectUuid,
179
+ projectRecorded: recordedProject === undefined,
180
+ application: { uuid: app.key, config: snapshotConfig(app.config) },
181
+ database: { uuid: db.key, config: snapshotConfig(db.config) },
182
+ },
183
+ };
184
+ }
@@ -183,3 +183,24 @@ export declare function desiredAgoraEnv(org: DeclaredOrg, recorded: Readonly<Rec
183
183
  };
184
184
  /** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
185
185
  export declare function agoraEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
186
+ /**
187
+ * #442: recorded once a restart has confirmed the org's live Agora process
188
+ * actually serves {@link agoraEnvRevision}'s digest of the env the executor
189
+ * last wrote -- distinct from Coolify's own env store, which `bulkUpsertEnvs`
190
+ * always leaves matching desired the moment the write succeeds, whether or
191
+ * not the running container was ever restarted onto it. Before this, a plan
192
+ * comparing only the live Coolify env (`agoraEnvDrift` above) could read a
193
+ * step as converged the instant the env was written, so a crash between that
194
+ * write and the restart -- or simply never restarting at all -- left the
195
+ * running process on the old config forever, with nothing left to replan
196
+ * against (#442's own probe: "one env write, zero restarts, zero deploys").
197
+ */
198
+ export declare const AGORA_ACTIVATED_ENV_SCOPE_KEY = "coolify_agora_activated_env";
199
+ /**
200
+ * A stable digest of `env`, for comparison against what was last recorded
201
+ * under {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}. `AGORA_CONFIG` is compared by
202
+ * its parsed structure rather than its raw string, the same rule
203
+ * {@link agoraEnvDrift}'s own `sameEnvValue` follows, so a cosmetic key-order
204
+ * difference in that JSON never forces a spurious restart.
205
+ */
206
+ export declare function agoraEnvRevision(env: Readonly<Record<string, string>>): string;
@@ -1,4 +1,5 @@
1
1
  import { chatUpstreams } from './chat-matrix.js';
2
+ import { FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY } from './core-files.js';
2
3
  /**
3
4
  * #164: what both the planner (`diff.ts`) and the executor (`executor.ts`)
4
5
  * need to agree on for an org's Agora -- the hostname, the values the host
@@ -218,6 +219,13 @@ export function desiredAgoraEnv(org, recorded, rawHost) {
218
219
  if (!clientId)
219
220
  missing.push('clientId');
220
221
  missing.push(...agoraHostMissing(host));
222
+ // #437: the core Files audience and the core collection
223
+ // (`core-files.ts`), both optional on `Agora` itself (`agoras.ts`) --
224
+ // unlike `messagingProjectId`, an org whose `files:organisation` step has
225
+ // not run yet (or not recorded either one) simply gets an Agora with
226
+ // Files off, never a blocked `AGORA_CONFIG`.
227
+ const filesProjectId = recorded[ZITADEL_FILES_PROJECT_SCOPE_KEY];
228
+ const filesCollectionId = recorded[FILES_CORE_COLLECTION_SCOPE_KEY];
221
229
  if (missing.length === 0) {
222
230
  env.AGORA_CONFIG = JSON.stringify({
223
231
  name: org.displayName,
@@ -227,6 +235,8 @@ export function desiredAgoraEnv(org, recorded, rawHost) {
227
235
  organisationId: zitadelOrgId,
228
236
  mailProjectId: host.mailProjectId,
229
237
  messagingProjectId: host.messagingProjectId,
238
+ ...(filesProjectId ? { filesProjectId } : {}),
239
+ ...(filesCollectionId ? { filesCollectionId } : {}),
230
240
  // Agora hides all chat UI unless this is true.
231
241
  chat: { enabled: org.chat.enabled },
232
242
  // Agora draws call buttons only when this is true.
@@ -282,3 +292,30 @@ export function agoraEnvDrift(desired, live) {
282
292
  })
283
293
  .map(([key]) => key);
284
294
  }
295
+ /**
296
+ * #442: recorded once a restart has confirmed the org's live Agora process
297
+ * actually serves {@link agoraEnvRevision}'s digest of the env the executor
298
+ * last wrote -- distinct from Coolify's own env store, which `bulkUpsertEnvs`
299
+ * always leaves matching desired the moment the write succeeds, whether or
300
+ * not the running container was ever restarted onto it. Before this, a plan
301
+ * comparing only the live Coolify env (`agoraEnvDrift` above) could read a
302
+ * step as converged the instant the env was written, so a crash between that
303
+ * write and the restart -- or simply never restarting at all -- left the
304
+ * running process on the old config forever, with nothing left to replan
305
+ * against (#442's own probe: "one env write, zero restarts, zero deploys").
306
+ */
307
+ export const AGORA_ACTIVATED_ENV_SCOPE_KEY = 'coolify_agora_activated_env';
308
+ /**
309
+ * A stable digest of `env`, for comparison against what was last recorded
310
+ * under {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}. `AGORA_CONFIG` is compared by
311
+ * its parsed structure rather than its raw string, the same rule
312
+ * {@link agoraEnvDrift}'s own `sameEnvValue` follows, so a cosmetic key-order
313
+ * difference in that JSON never forces a spurious restart.
314
+ */
315
+ export function agoraEnvRevision(env) {
316
+ const normalized = {};
317
+ for (const [key, value] of Object.entries(env)) {
318
+ normalized[key] = key === 'AGORA_CONFIG' ? stable(JSON.parse(value)) : value;
319
+ }
320
+ return stable(normalized);
321
+ }
@@ -1,5 +1,5 @@
1
1
  import type { DeclaredOrg, HostingEntry } from '../org.js';
2
- import type { PlatformStore } from '../store.js';
2
+ import type { DeclaredOrgStore, PlatformStore } from '../store.js';
3
3
  import type { CoolifyPort } from './executor.js';
4
4
  /**
5
5
  * F5 (https://github.com/wtfalch/foundry/issues/314): an entry with
@@ -71,9 +71,17 @@ export declare class PlatformBrokerPort implements BrokerPort {
71
71
  constructor(options: {
72
72
  store: Pick<PlatformStore, 'getServiceIds' | 'putServiceIds'>;
73
73
  coolify: Pick<CoolifyPort, 'listEnvs' | 'bulkUpsertEnvs' | 'deploy'>;
74
+ /**
75
+ * #441: the lease store (the `DeclaredOrgStore`). With it, `recordDeploy`
76
+ * holds the `platform` lease and fences its write; while another platform
77
+ * change holds it the record is skipped, and the broker's next deploy
78
+ * records it. Without it the write is unfenced, as before.
79
+ */
80
+ leases?: Pick<DeclaredOrgStore, 'acquireMutationLease' | 'releaseMutationLease'>;
74
81
  });
75
82
  appId(): Promise<string>;
76
83
  listEnvs(): Promise<{
84
+ uuid?: string;
77
85
  key: string;
78
86
  value: string;
79
87
  is_preview: boolean;
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { entryClientIdScopeKey } from './agora.js';
2
3
  import { entryHostnames } from './hostnames.js';
3
4
  import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, parseBrokerDeployRecord, } from './platform-diff.js';
@@ -99,11 +100,18 @@ export class PlatformBrokerPort {
99
100
  return (await this.options.store.getServiceIds())[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY];
100
101
  }
101
102
  async recordDeploy(value) {
102
- const ids = await this.options.store.getServiceIds();
103
- await this.options.store.putServiceIds({
104
- ...ids,
105
- [PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]: value,
106
- });
103
+ const { leases, store } = this.options;
104
+ const lease = await leases?.acquireMutationLease('platform', randomUUID(), 60_000);
105
+ if (lease && !('token' in lease))
106
+ return;
107
+ try {
108
+ const ids = await store.getServiceIds();
109
+ await store.putServiceIds({ ...ids, [PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]: value }, lease);
110
+ }
111
+ finally {
112
+ if (lease)
113
+ await leases?.releaseMutationLease(lease).catch(() => undefined);
114
+ }
107
115
  }
108
116
  }
109
117
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -62,7 +62,7 @@ export interface ChatSynapseHomeserverInput {
62
62
  masHost: string;
63
63
  masSharedSecret: string;
64
64
  }
65
- /** `homeserver.yaml`: federation and presence off, MAS for auth, the chat gate module on. */
65
+ /** `homeserver.yaml`: federation off, presence on (stable Synapse features only -- no MSC3026 `busy`, foundry#41), MAS for auth, the chat gate module on. */
66
66
  export declare function chatSynapseHomeserverYaml(input: ChatSynapseHomeserverInput): string;
67
67
  export interface ChatMasConfigInput {
68
68
  orgSlug: string;
@@ -43,7 +43,7 @@ export function chatAdminUser(orgSlug) {
43
43
  return `@chat-admin:${chatServerName(orgSlug)}`;
44
44
  }
45
45
  const q = (value) => JSON.stringify(value);
46
- /** `homeserver.yaml`: federation and presence off, MAS for auth, the chat gate module on. */
46
+ /** `homeserver.yaml`: federation off, presence on (stable Synapse features only -- no MSC3026 `busy`, foundry#41), MAS for auth, the chat gate module on. */
47
47
  export function chatSynapseHomeserverYaml(input) {
48
48
  return [
49
49
  '# Generated by @wtfalch/foundry (executor.ts, coolify:chat). Do not edit',
@@ -77,9 +77,17 @@ export function chatSynapseHomeserverYaml(input) {
77
77
  '# An empty list is the recommended way of disabling federation.',
78
78
  'federation_domain_whitelist: []',
79
79
  '',
80
- '# Presence is out of scope for v1 (foundry#41).',
80
+ // foundry#41, decided 2026-10-01: on for every company, stable Synapse
81
+ // features only. No `msc3026_enabled` here -- that experimental flag
82
+ // only gates the wire value `org.matrix.msc3026.busy`, and
83
+ // `ChatClient.announcePresence` (chat/src/chat/client.ts) only ever
84
+ // writes `online`/`unavailable`, never `busy`, so this homeserver has
85
+ // nothing to accept that flag would unlock. `ChatClient`'s `Presence`
86
+ // type still reads a foreign `busy` off `m.presence` if one ever
87
+ // arrives; dropping that read path per the same decision is
88
+ // wtfalch/chat's own follow-up, not this file's.
81
89
  'presence:',
82
- ' enabled: false',
90
+ ' enabled: true',
83
91
  '',
84
92
  'enable_authenticated_media: true',
85
93
  'max_upload_size: "50M"',
@@ -0,0 +1,25 @@
1
+ /**
2
+ * #437: the core Files contract every company gets, not only one with a
3
+ * custom hosting entry declaring `uses.files` -- the one ZITADEL audience
4
+ * Agora requests to read/browse Files (`agoras.ts`'s `filesProjectId`, the
5
+ * same `urn:zitadel:iam:org:project:id:<id>:aud` shape `messagingProjectId`
6
+ * already uses) and the one collection every file Agora itself uploads goes
7
+ * into (`agoras.ts`'s `filesCollectionId`; Files has no route to list an
8
+ * organisation's collections and an ordinary member cannot create one, so
9
+ * this has to exist before Agora ever tries).
10
+ *
11
+ * Both are idempotent by name: the ZITADEL project is found-or-created by
12
+ * `${org.slug}-files` the same way `coolifyChat`'s own messaging project is,
13
+ * and the collection by `CORE_FILES_COLLECTION_NAME` through
14
+ * `FilesOrganisationPort.ensureCollection` (SDK `ensureCollection`,
15
+ * wtfalch/files#127 -- already exposed on the port, no new Files capability
16
+ * needed). `executor.ts`'s `filesOrganisation` creates both, every apply,
17
+ * right after the organisation itself -- the same "regardless of whether it
18
+ * already ran" shape that method's own doc comment already describes.
19
+ */
20
+ /** The one collection every file Agora uploads through Files goes into, inside the org's own Files organisation (scoped there, so the name needs nothing org-specific). */
21
+ export declare const CORE_FILES_COLLECTION_NAME = "agora";
22
+ /** Recorded id of {@link CORE_FILES_COLLECTION_NAME}, read by `agora.ts`'s `desiredAgoraEnv` as `filesCollectionId`. */
23
+ export declare const FILES_CORE_COLLECTION_SCOPE_KEY = "files_core_collection";
24
+ /** Recorded id of the org's `${org.slug}-files` ZITADEL project, read by `agora.ts`'s `desiredAgoraEnv` as `filesProjectId`. */
25
+ export declare const ZITADEL_FILES_PROJECT_SCOPE_KEY = "zitadel_files_project";
@@ -0,0 +1,25 @@
1
+ /**
2
+ * #437: the core Files contract every company gets, not only one with a
3
+ * custom hosting entry declaring `uses.files` -- the one ZITADEL audience
4
+ * Agora requests to read/browse Files (`agoras.ts`'s `filesProjectId`, the
5
+ * same `urn:zitadel:iam:org:project:id:<id>:aud` shape `messagingProjectId`
6
+ * already uses) and the one collection every file Agora itself uploads goes
7
+ * into (`agoras.ts`'s `filesCollectionId`; Files has no route to list an
8
+ * organisation's collections and an ordinary member cannot create one, so
9
+ * this has to exist before Agora ever tries).
10
+ *
11
+ * Both are idempotent by name: the ZITADEL project is found-or-created by
12
+ * `${org.slug}-files` the same way `coolifyChat`'s own messaging project is,
13
+ * and the collection by `CORE_FILES_COLLECTION_NAME` through
14
+ * `FilesOrganisationPort.ensureCollection` (SDK `ensureCollection`,
15
+ * wtfalch/files#127 -- already exposed on the port, no new Files capability
16
+ * needed). `executor.ts`'s `filesOrganisation` creates both, every apply,
17
+ * right after the organisation itself -- the same "regardless of whether it
18
+ * already ran" shape that method's own doc comment already describes.
19
+ */
20
+ /** The one collection every file Agora uploads through Files goes into, inside the org's own Files organisation (scoped there, so the name needs nothing org-specific). */
21
+ export const CORE_FILES_COLLECTION_NAME = 'agora';
22
+ /** Recorded id of {@link CORE_FILES_COLLECTION_NAME}, read by `agora.ts`'s `desiredAgoraEnv` as `filesCollectionId`. */
23
+ export const FILES_CORE_COLLECTION_SCOPE_KEY = 'files_core_collection';
24
+ /** Recorded id of the org's `${org.slug}-files` ZITADEL project, read by `agora.ts`'s `desiredAgoraEnv` as `filesProjectId`. */
25
+ export const ZITADEL_FILES_PROJECT_SCOPE_KEY = 'zitadel_files_project';
@@ -26,6 +26,15 @@ export interface PlannedStep {
26
26
  step: PlanStep;
27
27
  action: StepAction;
28
28
  }
29
+ /**
30
+ * A {@link PlannedStep} before `system` is set. Planners build these; the one
31
+ * place a plan is returned (`withStepSystem`, `step-system.ts`) sets `system`
32
+ * from the action, so no planner names it by hand.
33
+ */
34
+ export interface DraftStep {
35
+ step: Omit<PlanStep, 'system'>;
36
+ action: StepAction;
37
+ }
29
38
  /**
30
39
  * Facts about the host's executor ports that an org's plan needs, so a step
31
40
  * whose port is not wired is reported blocked instead of planned (rule 2).
@@ -70,9 +79,8 @@ export declare function toPlan(detail: PlanDetail): Plan;
70
79
  * `hosting.issuerApps[]` entry rather than a single singleton, since an org
71
80
  * can declare more than one ZITADEL app. #80 adds two more singletons,
72
81
  * modeled on {@link planIssuer}: `planFilesOrganisation` and
73
- * `planAiOrganisation`, each planned before its own system's limit step
74
- * ({@link planStorage}, {@link planAiBudget}) so an org has somewhere for a
75
- * limit to land, and AI use is switched on, before either limit is ever set.
82
+ * `planAiOrganisation`, the second planned before {@link planAiBudget} so AI
83
+ * use is switched on before its own limit is ever set.
76
84
  * `planOwners` below adds one more singleton, in a different shape from the
77
85
  * rest: the org's owner, invited by ZITADEL email, idempotent on the
78
86
  * *recorded* resource id (`recordedIds`, sourced from