@wtfalch/foundry 0.16.16 → 0.17.1
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.
- package/dist/apply.d.ts +22 -1
- package/dist/bootstrap/bootstrap.d.ts +6 -1
- package/dist/bootstrap/bootstrap.js +26 -3
- package/dist/bootstrap/declare.d.ts +11 -1
- package/dist/bootstrap/declare.js +18 -6
- package/dist/index.d.ts +11 -6
- package/dist/index.js +8 -1
- package/dist/migrations/0038_calls_version_pin.sql +8 -0
- package/dist/migrations/0039_destroy_record.sql +7 -0
- package/dist/migrations/0040_company_included_uuid.sql +72 -0
- package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
- package/dist/migrations/0042_mutation_leases.sql +12 -0
- package/dist/migrations/0043_platform_names.sql +25 -0
- package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
- package/dist/org.d.ts +128 -3
- package/dist/org.js +186 -1
- package/dist/plan/adopt.d.ts +76 -0
- package/dist/plan/adopt.js +184 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +37 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +13 -5
- package/dist/plan/chat-matrix.d.ts +1 -2
- package/dist/plan/chat-matrix.js +15 -4
- package/dist/plan/core-files.d.ts +25 -0
- package/dist/plan/core-files.js +25 -0
- package/dist/plan/diff.d.ts +27 -3
- package/dist/plan/diff.js +264 -86
- package/dist/plan/edge-worker.d.ts +114 -0
- package/dist/plan/edge-worker.js +201 -0
- package/dist/plan/entry-ai-key.d.ts +3 -3
- package/dist/plan/entry-files-key.d.ts +3 -3
- package/dist/plan/entry-names-key.d.ts +73 -0
- package/dist/plan/entry-names-key.js +236 -0
- package/dist/plan/executor.d.ts +292 -24
- package/dist/plan/executor.js +745 -77
- package/dist/plan/index.d.ts +6 -2
- package/dist/plan/index.js +3 -1
- package/dist/plan/live.d.ts +9 -2
- package/dist/plan/login-host.d.ts +13 -2
- package/dist/plan/login-host.js +26 -7
- package/dist/plan/plan-apply.d.ts +153 -6
- package/dist/plan/plan-apply.js +639 -78
- package/dist/plan/platform-diff.d.ts +106 -1
- package/dist/plan/platform-diff.js +182 -2
- package/dist/plan/platform-org.d.ts +45 -1
- package/dist/plan/platform-org.js +34 -3
- package/dist/plan/platform-providers.d.ts +45 -1
- package/dist/plan/platform-providers.js +585 -16
- package/dist/plan/providers-readers.d.ts +2 -0
- package/dist/plan/providers-readers.js +4 -0
- package/dist/plan/rollout.d.ts +52 -0
- package/dist/plan/rollout.js +37 -0
- package/dist/plan/step-system.d.ts +22 -0
- package/dist/plan/step-system.js +38 -0
- package/dist/plan.d.ts +3 -0
- package/dist/platform.d.ts +22 -3
- package/dist/platform.js +6 -0
- package/dist/state.d.ts +13 -0
- package/dist/store/audit.js +15 -1
- package/dist/store/postgres.d.ts +1 -1
- package/dist/store/postgres.js +142 -15
- package/dist/store/schema.d.ts +202 -5
- package/dist/store/schema.js +18 -1
- package/dist/store.d.ts +47 -4
- package/dist/store.js +9 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/plan/agora.d.ts
CHANGED
|
@@ -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;
|
package/dist/plan/agora.js
CHANGED
|
@@ -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
|
|
103
|
-
await
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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;
|
|
@@ -90,7 +90,6 @@ export interface ChatServiceEnvInput {
|
|
|
90
90
|
agoraHost: string;
|
|
91
91
|
masClientId: string;
|
|
92
92
|
masClientSecret: string;
|
|
93
|
-
databaseUrl: string;
|
|
94
93
|
}
|
|
95
94
|
/** Every variable `chat/apps/chat-service/src/config.ts` requires, plus the internal Synapse address and port. */
|
|
96
95
|
export declare function chatServiceEnvs(input: ChatServiceEnvInput): Array<{
|
package/dist/plan/chat-matrix.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
90
|
+
' enabled: true',
|
|
83
91
|
'',
|
|
84
92
|
'enable_authenticated_media: true',
|
|
85
93
|
'max_upload_size: "50M"',
|
|
@@ -187,7 +195,10 @@ export function chatServiceEnvs(input) {
|
|
|
187
195
|
{ key: 'MAS_URL', value: upstreams.mas },
|
|
188
196
|
{ key: 'MAS_CLIENT_ID', value: input.masClientId },
|
|
189
197
|
{ key: 'MAS_CLIENT_SECRET', value: input.masClientSecret },
|
|
190
|
-
|
|
198
|
+
// DATABASE_URL_OWNER/DATABASE_URL are pushed by executor.ts's
|
|
199
|
+
// pushDatabaseUrl instead, not here (the same split coolifyApp and
|
|
200
|
+
// cmsSite follow, #239) -- that call mints a distinct <dbName>_rt
|
|
201
|
+
// runtime role and never overwrites an already-minted DATABASE_URL.
|
|
191
202
|
{ key: 'CHAT_ALLOWED_ORIGINS', value: `https://${input.agoraHost}` },
|
|
192
203
|
{ key: 'PORT', value: CHAT_SERVICE_PORT },
|
|
193
204
|
];
|
|
@@ -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';
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -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).
|
|
@@ -42,6 +51,8 @@ export interface PlanOrgOptions {
|
|
|
42
51
|
now?: () => number;
|
|
43
52
|
/** #374: the Archon that bills this org (`plan-apply.ts`'s `orgArchonUrl`), written into Boule's env as `ARCHON_URL`. `null` with no platform wired. */
|
|
44
53
|
archonUrl?: string | null;
|
|
54
|
+
/** #318: the executor was built with a `WorkersPort`, read by `planEdgeWorkers`. */
|
|
55
|
+
edgeWorkers?: boolean;
|
|
45
56
|
}
|
|
46
57
|
export interface PlanDetail {
|
|
47
58
|
orgId: OrgId;
|
|
@@ -70,9 +81,8 @@ export declare function toPlan(detail: PlanDetail): Plan;
|
|
|
70
81
|
* `hosting.issuerApps[]` entry rather than a single singleton, since an org
|
|
71
82
|
* can declare more than one ZITADEL app. #80 adds two more singletons,
|
|
72
83
|
* modeled on {@link planIssuer}: `planFilesOrganisation` and
|
|
73
|
-
* `planAiOrganisation`,
|
|
74
|
-
*
|
|
75
|
-
* limit to land, and AI use is switched on, before either limit is ever set.
|
|
84
|
+
* `planAiOrganisation`, the second planned before {@link planAiBudget} so AI
|
|
85
|
+
* use is switched on before its own limit is ever set.
|
|
76
86
|
* `planOwners` below adds one more singleton, in a different shape from the
|
|
77
87
|
* rest: the org's owner, invited by ZITADEL email, idempotent on the
|
|
78
88
|
* *recorded* resource id (`recordedIds`, sourced from
|
|
@@ -109,3 +119,17 @@ export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[]
|
|
|
109
119
|
export declare const AI_KEY_UNDELIVERED_SCOPE_KEY = "ai_key_undelivered";
|
|
110
120
|
/** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
|
|
111
121
|
export declare const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = "jobs_registration_undelivered";
|
|
122
|
+
/**
|
|
123
|
+
* #484: the spend cap foundry applies when an org declares no AI limit
|
|
124
|
+
* (`org.ai.limitMicros === null`) -- Archon creates every company that way,
|
|
125
|
+
* and AI's own `updateBudget` has no uncapped representation
|
|
126
|
+
* (`UpdateBudget.limitMicros` is a required string), so a null limit used to
|
|
127
|
+
* throw `ai:budget` out of every apply. William's Decision, 2026-10-05: 100
|
|
128
|
+
* EUR, in `ai`'s own USD-micros unit (`usdToMicros`,
|
|
129
|
+
* `ai/packages/service/src/money.ts`), billing to revisit the number later.
|
|
130
|
+
* 100 EUR at the ECB reference rate for 2 October 2026 (1.1225 USD/EUR, the
|
|
131
|
+
* most recent published rate as of the decision) is 112.25 USD = 112,250,000
|
|
132
|
+
* micros. A declared numeric limit always wins over this default --
|
|
133
|
+
* {@link effectiveAiLimitMicros} is the one place that substitutes it.
|
|
134
|
+
*/
|
|
135
|
+
export declare const DEFAULT_AI_LIMIT_MICROS = "112250000";
|