@wtfalch/foundry 0.32.0 → 0.33.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/bootstrap/bootstrap.d.ts +18 -1
- package/dist/bootstrap/bootstrap.js +167 -28
- package/dist/bootstrap/declare.d.ts +5 -1
- package/dist/bootstrap/declare.js +3 -1
- package/dist/bootstrap/index.d.ts +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -0
- package/dist/org.d.ts +6 -2
- package/dist/org.js +16 -0
- package/dist/plan/boule.d.ts +24 -1
- package/dist/plan/boule.js +29 -1
- package/dist/plan/diff.d.ts +3 -1
- package/dist/plan/diff.js +125 -9
- package/dist/plan/executor.d.ts +65 -7
- package/dist/plan/executor.js +144 -18
- package/dist/plan/health-targets.js +1 -1
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/plan-apply.d.ts +23 -0
- package/dist/plan/plan-apply.js +11 -0
- package/dist/plan/platform-diff.d.ts +33 -12
- package/dist/plan/platform-diff.js +95 -31
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-org.js +2 -0
- package/dist/plan/platform-providers.d.ts +51 -4
- package/dist/plan/platform-providers.js +296 -79
- package/dist/plan/sign-in-org.d.ts +99 -0
- package/dist/plan/sign-in-org.js +133 -0
- package/dist/plan.d.ts +18 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Plan } from '../plan.js';
|
|
2
|
-
import type { PlatformLiveReaders, PlatformStepExecutor, StepSystem } from '../plan/index.js';
|
|
2
|
+
import type { FoundryPlanApplyOptions, LiveReaders, PlatformLiveReaders, PlatformStepExecutor, StepExecutor, StepSystem } from '../plan/index.js';
|
|
3
3
|
import type { FoundryPlatformStepExecutorOptions } from '../plan/platform-providers.js';
|
|
4
4
|
import type { FoundryStoreDb } from '../store/postgres.js';
|
|
5
5
|
import type { BootstrapCompany, BootstrapOwner } from './declare.js';
|
|
@@ -38,6 +38,17 @@ export interface BootstrapProgress {
|
|
|
38
38
|
}
|
|
39
39
|
/** Everything foundry's per-step executors take besides the two clients and `deliverSecret`. */
|
|
40
40
|
export type BootstrapExecutorDeps = Omit<FoundryPlatformStepExecutorOptions, 'coolify' | 'deliverSecret'>;
|
|
41
|
+
/**
|
|
42
|
+
* What phase two needs to apply the platform's own org (Company Included):
|
|
43
|
+
* the org-side readers and step executor a host already builds for any org.
|
|
44
|
+
* Without it the run applies the platform only.
|
|
45
|
+
*/
|
|
46
|
+
export interface BootstrapCompanyDeps {
|
|
47
|
+
readers: LiveReaders;
|
|
48
|
+
executor: StepExecutor;
|
|
49
|
+
/** The same flags `FoundryPlanApply` takes: a step whose port the host did not build is reported blocked. */
|
|
50
|
+
options?: Pick<FoundryPlanApplyOptions, 'ownerInviteMail' | 'broker'>;
|
|
51
|
+
}
|
|
41
52
|
export interface BootstrapPlatformDeps {
|
|
42
53
|
/** Foundry's tables live here, already migrated by the host; the store, the audit log and the plan all use it. */
|
|
43
54
|
db: FoundryStoreDb;
|
|
@@ -54,6 +65,12 @@ export interface BootstrapPlatformDeps {
|
|
|
54
65
|
};
|
|
55
66
|
/** Per-step dependencies (auth, mail, files, ai, integrations, archon, sfu). A step without its own is reported blocked. */
|
|
56
67
|
executor?: BootstrapExecutorDeps;
|
|
68
|
+
/**
|
|
69
|
+
* Phase two: after the platform, apply the platform's own org's database,
|
|
70
|
+
* sign-in client, owner and Boule, then the platform once more so Archon
|
|
71
|
+
* picks up the keys that slice produced. Absent: platform only.
|
|
72
|
+
*/
|
|
73
|
+
company?: BootstrapCompanyDeps;
|
|
57
74
|
/**
|
|
58
75
|
* Passed to `createPostgresAuditLog`. Omit it in a real deployment: `db` is
|
|
59
76
|
* the runtime role the host made with `ensureRuntimeRole`, and the default
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { CloudflareClient } from '@wtfalch/providers/cloudflare';
|
|
2
2
|
import { CoolifyClient } from '@wtfalch/providers/coolify';
|
|
3
|
+
import { validateMail } from '../org.js';
|
|
4
|
+
import { BOULE_BROKER_ENTRY_KEY } from '../plan/boule.js';
|
|
5
|
+
import { brokerEntryAppliedScopeKey } from '../plan/broker-entries.js';
|
|
3
6
|
import { FoundryPlanApply, FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, } from '../plan/index.js';
|
|
4
7
|
import { createPostgresAuditLog } from '../store/audit.js';
|
|
5
8
|
import { createPostgresDeclaredOrgStore, createPostgresPlatformStore } from '../store/postgres.js';
|
|
6
|
-
import { COMPANY_INCLUDED_ORG_ID, declaredCompanyOrg, declaredPlatformFor
|
|
9
|
+
import { COMPANY_INCLUDED_ORG_ID, declaredCompanyOrg, declaredPlatformFor } from './declare.js';
|
|
7
10
|
import { driveApply } from './drive.js';
|
|
8
11
|
const REDACTED = '<redacted>';
|
|
9
12
|
/** Shorter strings in `deps.executor` are settings ("main", ports), not secrets, and would mangle every message. */
|
|
@@ -41,17 +44,47 @@ export class Redactor {
|
|
|
41
44
|
return out;
|
|
42
45
|
}
|
|
43
46
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
/** The platform, and the platform's own org when the host gave phase two its dependencies. */
|
|
48
|
+
function allowBootstrapApply(withCompany) {
|
|
49
|
+
return {
|
|
50
|
+
async canApply(_actor, orgId) {
|
|
51
|
+
return withCompany && orgId === COMPANY_INCLUDED_ORG_ID;
|
|
52
|
+
},
|
|
53
|
+
async canDestroy() {
|
|
54
|
+
return false;
|
|
55
|
+
},
|
|
56
|
+
async canApplyPlatform() {
|
|
57
|
+
return true;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The org steps phase two applies: the sign-in org and its domain, the owner
|
|
63
|
+
* (whose user id Boule's `AUTHZ_FIRST_OWNER` carries), Boule's sign-in client,
|
|
64
|
+
* and Boule with its database. Everything else the company declares (chat,
|
|
65
|
+
* mail, Boule's broker entry, which waits for the org's mail domain, ...) is
|
|
66
|
+
* for a later, ordinary org apply.
|
|
67
|
+
*/
|
|
68
|
+
const COMPANY_SLICE_STEPS = new Set([
|
|
69
|
+
'zitadel:issuer',
|
|
70
|
+
'zitadel:registration-closed',
|
|
71
|
+
'zitadel:domain',
|
|
72
|
+
'zitadel:owner',
|
|
73
|
+
'zitadel:app:boule',
|
|
74
|
+
'coolify:boule',
|
|
75
|
+
]);
|
|
76
|
+
const companySliceOnly = {
|
|
77
|
+
step: (action) => COMPANY_SLICE_STEPS.has(`${action.system}:${action.kind}`) ||
|
|
78
|
+
COMPANY_SLICE_STEPS.has(`${action.system}:${action.kind}:${action.key}`),
|
|
79
|
+
// `coolify:boule:mail` only says Boule runs without mail for now.
|
|
80
|
+
blocked: (key) => key !== 'coolify:boule:mail' &&
|
|
81
|
+
[...COMPANY_SLICE_STEPS].some((step) => key === step || key.startsWith(`${step}:`)),
|
|
54
82
|
};
|
|
83
|
+
/** Shown instead of a phase-two error that could carry a value; the failing step's own name and redacted text are in `failed`. */
|
|
84
|
+
const COMPANY_NOT_FINISHED = "the company's own slice did not finish, so Archon has no people-store keys yet; run onboarding again";
|
|
85
|
+
/** The step key and fixed text of the blocked item for Boule's missing broker entry. */
|
|
86
|
+
const BOULE_BROKER_ENTRY_BLOCKED_STEP = 'coolify:boule-broker-entry';
|
|
87
|
+
const BOULE_SIGN_IN_BLOCKED = "Boule is deployed but nobody can sign in to it yet: its broker entry waits for the company's mail domain, which the company's mail step records. Apply the company's mail, then its Boule broker entry (an ordinary org apply)";
|
|
55
88
|
/** `FoundryPlanApply` needs org-scoped parts this run never uses; each refuses if called. */
|
|
56
89
|
const unusedOrgExecutor = {
|
|
57
90
|
async execute() {
|
|
@@ -68,6 +101,25 @@ export function roundSystem(plan) {
|
|
|
68
101
|
return systems.size === 1 && only ? only : 'foundry';
|
|
69
102
|
}
|
|
70
103
|
const unusedOrgReader = { listResources: async () => [] };
|
|
104
|
+
/** Counts the org steps a round starts and ends, as the platform executor is wrapped below. */
|
|
105
|
+
function trackedOrgExecutor(inner, progress, redactor) {
|
|
106
|
+
return {
|
|
107
|
+
execute: async (action, context) => {
|
|
108
|
+
const p = progress();
|
|
109
|
+
p.started++;
|
|
110
|
+
try {
|
|
111
|
+
const result = await inner.execute(action, context);
|
|
112
|
+
p.completed++;
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
// After a failure too: the secrets it minted before failing must be masked in its error.
|
|
117
|
+
redactor.add(...(inner.secretsHandled?.() ?? []));
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
...(inner.ownEdgePorts ? { ownEdgePorts: () => inner.ownEdgePorts?.() ?? {} } : {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
71
123
|
/** The audit log with every free-text field run through the redactor before it is stored. */
|
|
72
124
|
function redactingAudit(inner, redactor) {
|
|
73
125
|
const plan = (p) => ({
|
|
@@ -115,6 +167,16 @@ export function guardStepDeps(executor, redactor, deliverSecret, heldKeys) {
|
|
|
115
167
|
const copy = { ...deps };
|
|
116
168
|
if ('deliverSecret' in copy)
|
|
117
169
|
copy.deliverSecret = deliverSecret;
|
|
170
|
+
const inviteMail = copy.ownerInviteMail;
|
|
171
|
+
if (inviteMail) {
|
|
172
|
+
// The link carries the invite code; both are masked from the first moment they exist.
|
|
173
|
+
copy.ownerInviteMail = {
|
|
174
|
+
send: (to, link) => {
|
|
175
|
+
redactor.add(link, new URL(link).searchParams.get('code'));
|
|
176
|
+
return inviteMail.send(to, link);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
118
180
|
// #201: ai's own default minter needs heldKeys too, to re-sign a mint
|
|
119
181
|
// request against its signing key when an earlier run pinned it but
|
|
120
182
|
// crashed before the management key itself was minted and delivered.
|
|
@@ -170,8 +232,14 @@ export async function bootstrapPlatform(input, deps) {
|
|
|
170
232
|
});
|
|
171
233
|
};
|
|
172
234
|
try {
|
|
173
|
-
|
|
235
|
+
// Refused here, before a credential is read or a row written: the work and
|
|
236
|
+
// personal addresses must be valid and not clash on the company's domain.
|
|
237
|
+
const mailProblem = validateMail(declaredCompanyOrg(input.company, input.owner));
|
|
238
|
+
if (mailProblem)
|
|
239
|
+
throw new Error(`bootstrapPlatform: ${mailProblem}`);
|
|
174
240
|
redactor.addDeep(deps.executor);
|
|
241
|
+
// The company executor's own tokens (its ports hold the Coolify and ZITADEL credentials).
|
|
242
|
+
redactor.addDeep(deps.company?.executor);
|
|
175
243
|
const credentials = await deps.rootCredentials();
|
|
176
244
|
redactor.add(credentials.coolifyToken, credentials.cloudflareApiToken, credentials.cloudflareAccountId, ...Object.values(credentials.accessHeaders ?? {}));
|
|
177
245
|
const deliverSecret = async (ref, secret) => {
|
|
@@ -272,9 +340,11 @@ export async function bootstrapPlatform(input, deps) {
|
|
|
272
340
|
? { driftedSteps: (platform, ids) => inner.driftedSteps?.(platform, ids) ?? {} }
|
|
273
341
|
: {}),
|
|
274
342
|
};
|
|
343
|
+
let orgProgress = { started: 0, completed: 0 };
|
|
344
|
+
const companyDeps = deps.company;
|
|
275
345
|
const planApply = new FoundryPlanApply({
|
|
276
346
|
store: orgStore,
|
|
277
|
-
readers: {
|
|
347
|
+
readers: companyDeps?.readers ?? {
|
|
278
348
|
zitadel: unusedOrgReader,
|
|
279
349
|
coolify: unusedOrgReader,
|
|
280
350
|
cloudflare: unusedOrgReader,
|
|
@@ -282,14 +352,17 @@ export async function bootstrapPlatform(input, deps) {
|
|
|
282
352
|
ai: unusedOrgReader,
|
|
283
353
|
cms: unusedOrgReader,
|
|
284
354
|
},
|
|
285
|
-
executor:
|
|
355
|
+
executor: companyDeps
|
|
356
|
+
? trackedOrgExecutor(companyDeps.executor, () => orgProgress, redactor)
|
|
357
|
+
: unusedOrgExecutor,
|
|
286
358
|
destroyer: unusedOrgExecutor,
|
|
287
359
|
audit: redactingAudit(createPostgresAuditLog(deps.db, { checkRuntimeRole: deps.checkRuntimeRole }), redactor),
|
|
288
|
-
authz:
|
|
360
|
+
authz: allowBootstrapApply(Boolean(companyDeps)),
|
|
289
361
|
actor: { id: 'bootstrap-platform', display: 'bootstrapPlatform' },
|
|
290
362
|
platform: { store: platformStore, readers: runtime.readers, executor },
|
|
363
|
+
...(companyDeps ? { ...companyDeps.options, onlySteps: companySliceOnly } : {}),
|
|
291
364
|
});
|
|
292
|
-
const
|
|
365
|
+
const drivePlatform = (label) => driveApply({
|
|
293
366
|
plan: () => planApply.planPlatform(),
|
|
294
367
|
apply: (plan, stepProgress) => {
|
|
295
368
|
progress = stepProgress;
|
|
@@ -300,32 +373,98 @@ export async function bootstrapPlatform(input, deps) {
|
|
|
300
373
|
phase: 'plan',
|
|
301
374
|
system: roundSystem(plan),
|
|
302
375
|
round,
|
|
303
|
-
message:
|
|
376
|
+
message: `${label}round ${round}: ${plan.steps.length} step(s) planned`,
|
|
304
377
|
steps: plan.steps.map((s) => s.description),
|
|
305
378
|
}),
|
|
306
379
|
onApplied: (round, plan) => emit({
|
|
307
380
|
phase: 'apply',
|
|
308
381
|
system: roundSystem(plan),
|
|
309
382
|
round,
|
|
310
|
-
message:
|
|
383
|
+
message: `${label}round ${round}: applied`,
|
|
311
384
|
steps: plan.steps.map((s) => s.description),
|
|
312
385
|
}),
|
|
313
386
|
});
|
|
314
|
-
|
|
387
|
+
// Phase one: the platform.
|
|
388
|
+
const driven = await drivePlatform('');
|
|
389
|
+
const applied = [...driven.applied];
|
|
390
|
+
let failed = driven.failed;
|
|
391
|
+
let lastPlan = driven.lastPlan;
|
|
392
|
+
const blockedRaw = (driven.failed ? [] : (driven.lastPlan?.unmanaged ?? []))
|
|
315
393
|
.filter((item) => item.key !== undefined)
|
|
316
|
-
.map((item) => ({
|
|
317
|
-
|
|
318
|
-
|
|
394
|
+
.map((item) => ({ step: item.key, message: item.description }));
|
|
395
|
+
if (companyDeps && !failed) {
|
|
396
|
+
// Phase two: the platform's own org. Its Boule needs the platform's
|
|
397
|
+
// sign-in, and Archon's people-store keys need that Boule.
|
|
398
|
+
const company = await driveApply({
|
|
399
|
+
plan: () => planApply.plan(COMPANY_INCLUDED_ORG_ID),
|
|
400
|
+
apply: (plan, stepProgress) => {
|
|
401
|
+
orgProgress = stepProgress;
|
|
402
|
+
return planApply.apply(plan, '');
|
|
403
|
+
},
|
|
404
|
+
maxRounds: deps.maxRounds,
|
|
405
|
+
onPlan: (round, plan) => emit({
|
|
406
|
+
phase: 'plan',
|
|
407
|
+
system: roundSystem(plan),
|
|
408
|
+
round,
|
|
409
|
+
message: `company round ${round}: ${plan.steps.length} step(s) planned`,
|
|
410
|
+
steps: plan.steps.map((s) => s.description),
|
|
411
|
+
}),
|
|
412
|
+
onApplied: (round, plan) => emit({
|
|
413
|
+
phase: 'apply',
|
|
414
|
+
system: roundSystem(plan),
|
|
415
|
+
round,
|
|
416
|
+
message: `company round ${round}: applied`,
|
|
417
|
+
steps: plan.steps.map((s) => s.description),
|
|
418
|
+
}),
|
|
419
|
+
});
|
|
420
|
+
applied.push(...company.applied);
|
|
421
|
+
if (company.failed) {
|
|
422
|
+
failed = company.failed;
|
|
423
|
+
blockedRaw.push({ step: 'company', message: COMPANY_NOT_FINISHED });
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
for (const item of company.lastPlan?.unmanaged ?? []) {
|
|
427
|
+
if (item.key !== undefined)
|
|
428
|
+
blockedRaw.push({ step: item.key, message: item.description });
|
|
429
|
+
}
|
|
430
|
+
// Boule's broker entry needs the org's mail domain, which only the org's mail step
|
|
431
|
+
// records, so it is outside this slice. Until it is applied nobody can sign in to Boule:
|
|
432
|
+
// say so, and never report the onboarding complete.
|
|
433
|
+
const companyApplied = await orgStore.getAppliedState(COMPANY_INCLUDED_ORG_ID);
|
|
434
|
+
if (!companyApplied?.serviceIds[brokerEntryAppliedScopeKey(BOULE_BROKER_ENTRY_KEY)]) {
|
|
435
|
+
blockedRaw.push({
|
|
436
|
+
step: BOULE_BROKER_ENTRY_BLOCKED_STEP,
|
|
437
|
+
message: BOULE_SIGN_IN_BLOCKED,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
// Phase three: the same platform plan again. Archon's step now sees
|
|
441
|
+
// the keys Boule holds and writes its own copies.
|
|
442
|
+
const again = await drivePlatform('after the company: ');
|
|
443
|
+
applied.push(...again.applied);
|
|
444
|
+
failed = again.failed;
|
|
445
|
+
lastPlan = again.lastPlan;
|
|
446
|
+
if (!again.failed) {
|
|
447
|
+
for (const item of again.lastPlan?.unmanaged ?? []) {
|
|
448
|
+
if (item.key !== undefined && !blockedRaw.some((b) => b.step === item.key)) {
|
|
449
|
+
blockedRaw.push({ step: item.key, message: item.description });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const blocked = blockedRaw.map((item) => ({
|
|
456
|
+
step: item.step,
|
|
457
|
+
message: redactor.text(item.message),
|
|
319
458
|
}));
|
|
320
459
|
const result = {
|
|
321
|
-
applied:
|
|
460
|
+
applied: applied.map((s) => redactor.text(s)),
|
|
322
461
|
blocked,
|
|
323
|
-
complete: !
|
|
324
|
-
...(
|
|
462
|
+
complete: !failed && blocked.length === 0 && lastPlan?.steps.length === 0,
|
|
463
|
+
...(failed
|
|
325
464
|
? {
|
|
326
465
|
failed: {
|
|
327
|
-
step: redactor.text(
|
|
328
|
-
message: redactor.text(
|
|
466
|
+
step: redactor.text(failed.step),
|
|
467
|
+
message: redactor.text(failed.message),
|
|
329
468
|
},
|
|
330
469
|
}
|
|
331
470
|
: {}),
|
|
@@ -333,7 +472,7 @@ export async function bootstrapPlatform(input, deps) {
|
|
|
333
472
|
emit({
|
|
334
473
|
phase: 'done',
|
|
335
474
|
system: 'foundry',
|
|
336
|
-
message:
|
|
475
|
+
message: failed
|
|
337
476
|
? `stopped: ${result.failed?.message}`
|
|
338
477
|
: `${result.applied.length} step(s) applied, ${blocked.length} blocked`,
|
|
339
478
|
});
|
|
@@ -42,11 +42,15 @@ export interface BootstrapCompany {
|
|
|
42
42
|
export interface BootstrapOwner {
|
|
43
43
|
givenName: string;
|
|
44
44
|
familyName: string;
|
|
45
|
+
/** The owner's personal address: the first-sign-in link is mailed here. */
|
|
45
46
|
email: string;
|
|
47
|
+
/** The owner's work address on the company's domain; their sign-in name is its local part. */
|
|
48
|
+
mailboxAddress: string;
|
|
46
49
|
}
|
|
47
50
|
/**
|
|
48
51
|
* The owner as `OwnerIdentity`, trimmed. Throws a plain error naming the
|
|
49
|
-
* field when one is empty or
|
|
52
|
+
* field when one is empty or an address is not an address. The work address
|
|
53
|
+
* is required: the owner signs in as its local part.
|
|
50
54
|
*/
|
|
51
55
|
export declare function ownerIdentity(owner: BootstrapOwner): OwnerIdentity;
|
|
52
56
|
/**
|
|
@@ -59,13 +59,15 @@ const SERVICES_ENABLED_BY_DEFAULT = new Set([
|
|
|
59
59
|
]);
|
|
60
60
|
/**
|
|
61
61
|
* The owner as `OwnerIdentity`, trimmed. Throws a plain error naming the
|
|
62
|
-
* field when one is empty or
|
|
62
|
+
* field when one is empty or an address is not an address. The work address
|
|
63
|
+
* is required: the owner signs in as its local part.
|
|
63
64
|
*/
|
|
64
65
|
export function ownerIdentity(owner) {
|
|
65
66
|
const identity = {
|
|
66
67
|
email: owner.email?.trim() ?? '',
|
|
67
68
|
givenName: owner.givenName?.trim() ?? '',
|
|
68
69
|
familyName: owner.familyName?.trim() ?? '',
|
|
70
|
+
mailboxAddress: owner.mailboxAddress?.trim() ?? '',
|
|
69
71
|
};
|
|
70
72
|
const problem = validateOwners([identity]);
|
|
71
73
|
if (problem)
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export type { BootstrapCompany, BootstrapOwner, } from './declare.js';
|
|
2
|
-
export type { BootstrapExecutorDeps, BootstrapPlatformDeps, BootstrapPlatformInput, BootstrapProgress, BootstrapResult, RootCredentials, } from './bootstrap.js';
|
|
2
|
+
export type { BootstrapCompanyDeps, BootstrapExecutorDeps, BootstrapPlatformDeps, BootstrapPlatformInput, BootstrapProgress, BootstrapResult, RootCredentials, } from './bootstrap.js';
|
|
3
3
|
export { bootstrapPlatform } from './bootstrap.js';
|
package/dist/index.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requ
|
|
|
10
10
|
export type { SlugRejectionReason, SlugRejection } from './slug.js';
|
|
11
11
|
export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
|
|
12
12
|
export type { Checkpoint, LastApplied, AppliedState, DestroyRecord, DestroyStepRecord, } from './state.js';
|
|
13
|
-
export type { PlanStep, UnmanagedItem, Plan, VersionChangeItem } from './plan.js';
|
|
13
|
+
export type { PlanStep, UnmanagedItem, PlanNote, Plan, VersionChangeItem } from './plan.js';
|
|
14
14
|
export type { AppliedVersion, AppliedVersionsStore, DeclaredOrgStore, HeldLease, MutationLease, PlatformStore, RequestedChangeStore, StaleUpdate, UpdateOptions, } from './store.js';
|
|
15
15
|
export { StaleLeaseError } from './store.js';
|
|
16
16
|
export type { PlanApply, DestroyProgress } from './apply.js';
|
|
@@ -29,6 +29,8 @@ export type { PlatformOrgClientFactories } from './plan/index.js';
|
|
|
29
29
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
|
|
30
30
|
export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, LoginHostBinding, LoginHostBindingsPort, } from './plan/index.js';
|
|
31
31
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
|
32
|
+
export type { EnsureSignInOrgOptions } from './plan/index.js';
|
|
33
|
+
export { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ensureSignInOrg, registerSignInDomain, signInOrgDomain, } from './plan/index.js';
|
|
32
34
|
export { HttpJobsSchedulerClient } from './plan/index.js';
|
|
33
35
|
export type { HealthTarget, HealthTargetDepth, HealthTargetExpect } from './plan/index.js';
|
|
34
36
|
export { healthTargets, platformHealthTargets } from './plan/index.js';
|
|
@@ -58,7 +60,7 @@ export { mintLivekitCredentials } from './calls-sfu-keys.js';
|
|
|
58
60
|
export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './plan/index.js';
|
|
59
61
|
export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './plan/index.js';
|
|
60
62
|
export { migrationsDir as MIGRATIONS_DIR } from './migrations-dir.js';
|
|
61
|
-
export type { BootstrapCompany, BootstrapExecutorDeps, BootstrapOwner, BootstrapPlatformDeps, BootstrapPlatformInput, BootstrapProgress, BootstrapResult, RootCredentials, } from './bootstrap/index.js';
|
|
63
|
+
export type { BootstrapCompany, BootstrapCompanyDeps, BootstrapExecutorDeps, BootstrapOwner, BootstrapPlatformDeps, BootstrapPlatformInput, BootstrapProgress, BootstrapResult, RootCredentials, } from './bootstrap/index.js';
|
|
62
64
|
export { bootstrapPlatform } from './bootstrap/index.js';
|
|
63
65
|
export type { OrgAppVersionRef, OrgAppVersionStatus, WtfalchPackageDrift, PlatformVersionStatus, ReadPlatformVersionStatusOptions, StatusCoolifyPort, StatusGithubPort, StatusGhExec, } from './plan/index.js';
|
|
64
66
|
export { readPlatformVersionStatus, wtfalchVersionsFromPnpmLockfile, providersStatusCoolifyPort, ghCliStatusGithubPort, } from './plan/index.js';
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan
|
|
|
17
17
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
|
|
18
18
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
|
|
19
19
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
|
20
|
+
export { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ensureSignInOrg, registerSignInDomain, signInOrgDomain, } from './plan/index.js';
|
|
20
21
|
export { HttpJobsSchedulerClient } from './plan/index.js';
|
|
21
22
|
export { healthTargets, platformHealthTargets } from './plan/index.js';
|
|
22
23
|
export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeLoginHostResolver, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
|
package/dist/org.d.ts
CHANGED
|
@@ -674,8 +674,12 @@ export declare function validateBuyerIdentity(buyer: BuyerIdentity): string | nu
|
|
|
674
674
|
* recorded invited user id, not on this declared value, so editing an owner
|
|
675
675
|
* after the invite has gone out does not send a second one.
|
|
676
676
|
*
|
|
677
|
-
* `
|
|
678
|
-
*
|
|
677
|
+
* `email` is the owner's personal address: it receives the first-sign-in link
|
|
678
|
+
* and is kept as the user's `personal_email` metadata. `mailboxAddress` is the
|
|
679
|
+
* owner's work address; the owner signs in as its local part on the org's domain.
|
|
680
|
+
* The first owner falls back to `mail.ownerMailboxAddress` when it is unset. An
|
|
681
|
+
* owner with neither is a blocked item in the plan, not refused here, because a
|
|
682
|
+
* stored owner from before this rule has none.
|
|
679
683
|
*/
|
|
680
684
|
export interface OwnerIdentity {
|
|
681
685
|
email: string;
|
package/dist/org.js
CHANGED
|
@@ -746,6 +746,7 @@ const OWNER_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
746
746
|
export function validateOwners(owners) {
|
|
747
747
|
const seen = new Set();
|
|
748
748
|
const boxes = new Set();
|
|
749
|
+
const names = new Set();
|
|
749
750
|
for (const owner of owners) {
|
|
750
751
|
if (!OWNER_EMAIL_PATTERN.test(owner.email)) {
|
|
751
752
|
return `owner email '${owner.email}' is not a valid email address`;
|
|
@@ -762,6 +763,12 @@ export function validateOwners(owners) {
|
|
|
762
763
|
if (boxes.has(box))
|
|
763
764
|
return `owner mailboxAddress '${owner.mailboxAddress}' is listed more than once`;
|
|
764
765
|
boxes.add(box);
|
|
766
|
+
// One sign-in account per local part: the account's username is the part before the @.
|
|
767
|
+
const name = box.slice(0, box.indexOf('@'));
|
|
768
|
+
if (names.has(name)) {
|
|
769
|
+
return `owner work addresses must have different names before the @: '${name}' is used twice`;
|
|
770
|
+
}
|
|
771
|
+
names.add(name);
|
|
765
772
|
}
|
|
766
773
|
const key = owner.email.toLowerCase();
|
|
767
774
|
if (seen.has(key))
|
|
@@ -814,6 +821,15 @@ function validateOwnerMailboxes(org) {
|
|
|
814
821
|
const address = owner.mailboxAddress;
|
|
815
822
|
if (address === undefined || !OWNER_EMAIL_PATTERN.test(address))
|
|
816
823
|
continue;
|
|
824
|
+
// Only an owner with a work address is checked: a stored owner without one predates the
|
|
825
|
+
// rule, and refusing it would stop every later update of its org.
|
|
826
|
+
if (org.domain && OWNER_EMAIL_PATTERN.test(owner.email)) {
|
|
827
|
+
const personalDomain = owner.email.slice(owner.email.indexOf('@') + 1).toLowerCase();
|
|
828
|
+
const orgDomain = org.domain.toLowerCase();
|
|
829
|
+
if (personalDomain === orgDomain || personalDomain.endsWith(`.${orgDomain}`)) {
|
|
830
|
+
return `owner ${owner.email} personal address must not be on ${org.domain} or a subdomain of it`;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
817
833
|
if (mail.usesDomain && org.domain) {
|
|
818
834
|
const addressDomain = address.slice(address.indexOf('@') + 1).toLowerCase();
|
|
819
835
|
if (addressDomain !== org.domain.toLowerCase()) {
|
package/dist/plan/boule.d.ts
CHANGED
|
@@ -28,6 +28,14 @@ export declare const BOULE_DATABASE_SCOPE_KEY = "coolify_boule_database";
|
|
|
28
28
|
* sat built but never started, and `restart` cannot fix that.
|
|
29
29
|
*/
|
|
30
30
|
export declare const BOULE_DEPLOYED_SCOPE_KEY = "coolify_boule_deployed";
|
|
31
|
+
/**
|
|
32
|
+
* Recorded once the platform's own Boule was restarted (or deployed) after its
|
|
33
|
+
* peer password and bridge secret were written. Boule creates its people-store
|
|
34
|
+
* role from the password only at boot, so a password written without that
|
|
35
|
+
* restart is useless; without this marker a failed restart was never repeated,
|
|
36
|
+
* because the next run finds the secrets present and mints and changes nothing.
|
|
37
|
+
*/
|
|
38
|
+
export declare const BOULE_SECRETS_RESTARTED_SCOPE_KEY = "coolify_boule_secrets_restarted";
|
|
31
39
|
/**
|
|
32
40
|
* `boule.<domain>` when the org has a domain, `<slug>-boule.oikos.host`
|
|
33
41
|
* otherwise (the tier rule of boule#128 and foundry#147; `../slug.ts`'s
|
|
@@ -56,8 +64,23 @@ export declare function bouleMailFrom(org: DeclaredOrg, mailDomain: string): str
|
|
|
56
64
|
* values in memory only; the result names nothing secret.
|
|
57
65
|
*/
|
|
58
66
|
export declare function bouleMailTokenDrift(liveToken: string | undefined, appToken: string | undefined): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* The password of the Postgres role Archon connects to the owner company's
|
|
69
|
+
* people store with. Minted once, only for the platform's own org, and held in
|
|
70
|
+
* Boule's env; the plan compares its presence in memory and never plans with
|
|
71
|
+
* its value. Not in {@link desiredBouleEnv}: it is a secret.
|
|
72
|
+
*/
|
|
73
|
+
export declare const BOULE_PEER_PASSWORD_ENV = "AUTHZ_PEER_PASSWORD_OPERATOR";
|
|
74
|
+
/**
|
|
75
|
+
* The secret Archon sends to Boule's invite route as a bearer token. Minted
|
|
76
|
+
* once, only for the platform's own org, held in Boule's env and in Archon's
|
|
77
|
+
* env; compared in memory like {@link BOULE_PEER_PASSWORD_ENV}.
|
|
78
|
+
*/
|
|
79
|
+
export declare const BOULE_BRIDGE_SECRET_ENV = "ARCHON_BRIDGE_SECRET";
|
|
80
|
+
/** The role Boule creates from {@link BOULE_PEER_PASSWORD_ENV}, with grants on schema `authz` only. */
|
|
81
|
+
export declare const BOULE_PEER_ROLE = "authz_operator_rt";
|
|
59
82
|
/** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
|
|
60
|
-
export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN"];
|
|
83
|
+
export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "AUTHZ_FIRST_OWNER", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN", "AUTHZ_PEER_PASSWORD_OPERATOR", "ARCHON_BRIDGE_SECRET"];
|
|
61
84
|
/**
|
|
62
85
|
* The env vars an org's Boule app should carry, and every input still missing
|
|
63
86
|
* to build them. Callers that write must refuse on a non-empty `missing`: a
|
package/dist/plan/boule.js
CHANGED
|
@@ -28,6 +28,14 @@ export const BOULE_DATABASE_SCOPE_KEY = 'coolify_boule_database';
|
|
|
28
28
|
* sat built but never started, and `restart` cannot fix that.
|
|
29
29
|
*/
|
|
30
30
|
export const BOULE_DEPLOYED_SCOPE_KEY = 'coolify_boule_deployed';
|
|
31
|
+
/**
|
|
32
|
+
* Recorded once the platform's own Boule was restarted (or deployed) after its
|
|
33
|
+
* peer password and bridge secret were written. Boule creates its people-store
|
|
34
|
+
* role from the password only at boot, so a password written without that
|
|
35
|
+
* restart is useless; without this marker a failed restart was never repeated,
|
|
36
|
+
* because the next run finds the secrets present and mints and changes nothing.
|
|
37
|
+
*/
|
|
38
|
+
export const BOULE_SECRETS_RESTARTED_SCOPE_KEY = 'coolify_boule_secrets_restarted';
|
|
31
39
|
/**
|
|
32
40
|
* `boule.<domain>` when the org has a domain, `<slug>-boule.oikos.host`
|
|
33
41
|
* otherwise (the tier rule of boule#128 and foundry#147; `../slug.ts`'s
|
|
@@ -66,6 +74,21 @@ export function bouleMailFrom(org, mailDomain) {
|
|
|
66
74
|
export function bouleMailTokenDrift(liveToken, appToken) {
|
|
67
75
|
return !liveToken || (Boolean(appToken) && appToken !== liveToken);
|
|
68
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* The password of the Postgres role Archon connects to the owner company's
|
|
79
|
+
* people store with. Minted once, only for the platform's own org, and held in
|
|
80
|
+
* Boule's env; the plan compares its presence in memory and never plans with
|
|
81
|
+
* its value. Not in {@link desiredBouleEnv}: it is a secret.
|
|
82
|
+
*/
|
|
83
|
+
export const BOULE_PEER_PASSWORD_ENV = 'AUTHZ_PEER_PASSWORD_OPERATOR';
|
|
84
|
+
/**
|
|
85
|
+
* The secret Archon sends to Boule's invite route as a bearer token. Minted
|
|
86
|
+
* once, only for the platform's own org, held in Boule's env and in Archon's
|
|
87
|
+
* env; compared in memory like {@link BOULE_PEER_PASSWORD_ENV}.
|
|
88
|
+
*/
|
|
89
|
+
export const BOULE_BRIDGE_SECRET_ENV = 'ARCHON_BRIDGE_SECRET';
|
|
90
|
+
/** The role Boule creates from {@link BOULE_PEER_PASSWORD_ENV}, with grants on schema `authz` only. */
|
|
91
|
+
export const BOULE_PEER_ROLE = 'authz_operator_rt';
|
|
69
92
|
/** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
|
|
70
93
|
export const BOULE_MANAGED_ENV_KEYS = [
|
|
71
94
|
'APP_URL',
|
|
@@ -74,9 +97,12 @@ export const BOULE_MANAGED_ENV_KEYS = [
|
|
|
74
97
|
'AUTH_CLIENT_ID',
|
|
75
98
|
'BOULE_ORG_NAME',
|
|
76
99
|
'AUTHZ_BOOTSTRAP_OPERATOR',
|
|
100
|
+
'AUTHZ_FIRST_OWNER',
|
|
77
101
|
'ARCHON_URL',
|
|
78
102
|
'MAIL_FROM',
|
|
79
103
|
BOULE_MAIL_TOKEN_ENV,
|
|
104
|
+
BOULE_PEER_PASSWORD_ENV,
|
|
105
|
+
BOULE_BRIDGE_SECRET_ENV,
|
|
80
106
|
];
|
|
81
107
|
/**
|
|
82
108
|
* The env vars an org's Boule app should carry, and every input still missing
|
|
@@ -129,8 +155,10 @@ export function desiredBouleEnv(org, recorded, issuer, archonUrl, mailWanted = f
|
|
|
129
155
|
const ownerUserId = owner
|
|
130
156
|
? recorded[`zitadel:owner:${org.id}:${owner.email.toLowerCase()}`]
|
|
131
157
|
: undefined;
|
|
132
|
-
if (ownerUserId)
|
|
158
|
+
if (ownerUserId) {
|
|
133
159
|
env.AUTHZ_BOOTSTRAP_OPERATOR = ownerUserId;
|
|
160
|
+
env.AUTHZ_FIRST_OWNER = ownerUserId;
|
|
161
|
+
}
|
|
134
162
|
const mailDomain = recorded[MAIL_DOMAIN_RECORDED_KEY];
|
|
135
163
|
if (mailWanted && mailDomain)
|
|
136
164
|
env.MAIL_FROM = bouleMailFrom(org, mailDomain);
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -55,6 +55,8 @@ export interface PlanOrgOptions {
|
|
|
55
55
|
archonUrl?: string | null;
|
|
56
56
|
/** #318: the executor was built with a `WorkersPort`, read by `planWorkers`. */
|
|
57
57
|
edgeWorkers?: boolean;
|
|
58
|
+
/** The executor was built with an `ownerInviteMail` port; without it each owner's invite is blocked. */
|
|
59
|
+
ownerInviteMail?: boolean;
|
|
58
60
|
/** The platform's own service hostnames: `edgeReservedLabels` reserves them under the org's domain, and a wildcard Worker route (`planWorkers`) keeps them out. */
|
|
59
61
|
platformHostnames?: readonly string[];
|
|
60
62
|
}
|
|
@@ -134,7 +136,7 @@ export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[]
|
|
|
134
136
|
* computed `auth.<domain>` against, so the platform's own org never plans
|
|
135
137
|
* its login host equal to the shared ZITADEL instance's own.
|
|
136
138
|
*/
|
|
137
|
-
export declare function isPlatformOwnOrg(org: DeclaredOrg,
|
|
139
|
+
export declare function isPlatformOwnOrg(org: Pick<DeclaredOrg, 'id'>, platformOwnerOrgId: string | null): boolean;
|
|
138
140
|
/**
|
|
139
141
|
* What {@link planOrgPostgres} reports about the org's one shared Postgres
|
|
140
142
|
* server and its fixed schema topology (#444a, "manifest-and-plan-only"):
|