@wtfalch/foundry 0.31.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/apply.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { OrgId } from './org.js';
2
2
  import type { Plan } from './plan.js';
3
+ import type { FoundryApplyOptions } from './plan/plan-apply.js';
3
4
  import type { AppliedState } from './state.js';
4
5
  /** One destroy step starting, finishing or failing; `index` counts from 1 up to `total`. */
5
6
  export interface DestroyProgress {
@@ -30,8 +31,12 @@ export interface PlanApply {
30
31
  * required whenever `plan` contains a step with `destroys` or `migrates`
31
32
  * true (#9). Implementations must refuse to apply when the live state a
32
33
  * fresh plan would compute no longer matches this `plan` (#9, #10).
34
+ * @param options `confirmUpgrade` names the services whose version change
35
+ * is confirmed. Implementations must refuse, before any step runs, a plan
36
+ * whose `versionChanges` holds a `needsConfirmation` line for a service
37
+ * `confirmUpgrade` does not name exactly.
33
38
  */
34
- apply(plan: Plan, confirmedOrgName: string): Promise<AppliedState>;
39
+ apply(plan: Plan, confirmedOrgName: string, options?: FoundryApplyOptions): Promise<AppliedState>;
35
40
  /**
36
41
  * Deletes every resource foundry created and recorded for `id`, then the
37
42
  * declared org itself.
@@ -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, ownerIdentity, } from './declare.js';
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
- const allowPlatformApply = {
45
- async canApply() {
46
- return false;
47
- },
48
- async canDestroy() {
49
- return false;
50
- },
51
- async canApplyPlatform() {
52
- return true;
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
- ownerIdentity(input.owner);
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: unusedOrgExecutor,
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: allowPlatformApply,
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 driven = await driveApply({
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: `round ${round}: ${plan.steps.length} step(s) planned`,
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: `round ${round}: applied`,
383
+ message: `${label}round ${round}: applied`,
311
384
  steps: plan.steps.map((s) => s.description),
312
385
  }),
313
386
  });
314
- const blocked = (driven.failed ? [] : (driven.lastPlan?.unmanaged ?? []))
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
- step: item.key,
318
- message: redactor.text(item.description),
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: driven.applied.map((s) => redactor.text(s)),
460
+ applied: applied.map((s) => redactor.text(s)),
322
461
  blocked,
323
- complete: !driven.failed && blocked.length === 0 && driven.lastPlan?.steps.length === 0,
324
- ...(driven.failed
462
+ complete: !failed && blocked.length === 0 && lastPlan?.steps.length === 0,
463
+ ...(failed
325
464
  ? {
326
465
  failed: {
327
- step: redactor.text(driven.failed.step),
328
- message: redactor.text(driven.failed.message),
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: driven.failed
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 the email is not an address.
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 the email is not an address.
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 } 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';
@@ -18,17 +18,19 @@ export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, cre
18
18
  export { createPostgresAuditLog } from './store/audit.js';
19
19
  export type { FoundryStoreDb } from './store/postgres.js';
20
20
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type AppliedVersionRow, type NewAppliedVersionRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
21
- export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, StepSystem, PlanDetail, OrgPostgresPlan, PlatformPlanDetail, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
21
+ export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, StepSystem, PlanDetail, OrgPostgresPlan, PlatformPlanDetail, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, FoundryApplyOptions, UnconfirmedUpgrade, } from './plan/index.js';
22
22
  export { AdoptRefusedError } from './plan/index.js';
23
23
  export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
24
24
  export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, DnsPort, EdgePlatformAccess, EdgePlatformDns, EdgePlatformRateLimits, EdgePlatformWorkers, EdgePlatformZones, EdgeZonesOptions, ZonePort, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, EdgeWorkersOptions, EdgePlatformTunnels, TunnelPort, } from './plan/index.js';
25
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
25
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
26
26
  export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
27
27
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
28
28
  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
@@ -13,10 +13,11 @@ export { createPostgresAuditLog } from './store/audit.js';
13
13
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
14
14
  export { AdoptRefusedError } from './plan/index.js';
15
15
  export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
16
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
16
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
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
@@ -288,7 +288,12 @@ export declare const APP_ENTRY_KEY = "app";
288
288
  export interface HostingEntry {
289
289
  /** A DNS label, unique per org. It goes into step ids and scope keys, so it never holds `:` or `.`. */
290
290
  key: string;
291
- /** The repo under `wtfalch` the app builds from. `null` is the shared standard app (`executor.ts`'s `DEFAULT_APP_REPO`). */
291
+ /**
292
+ * The repo under `wtfalch` the app builds from. `null` is the shape
293
+ * migration 0029 wrote for every org with no `bespokeAppRepo`; there is no
294
+ * shared default app any more (#447), so `plan/diff.ts`'s `planApp` blocks
295
+ * it at plan time instead of provisioning it.
296
+ */
292
297
  repo: string | null;
293
298
  /** The app's folder in `repo`. `null` is the repo root. */
294
299
  folder: string | null;
@@ -669,8 +674,12 @@ export declare function validateBuyerIdentity(buyer: BuyerIdentity): string | nu
669
674
  * recorded invited user id, not on this declared value, so editing an owner
670
675
  * after the invite has gone out does not send a second one.
671
676
  *
672
- * `mailboxAddress` is the owner's own mailbox, when mail is on. The first
673
- * owner falls back to `mail.ownerMailboxAddress` when it is unset.
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.
674
683
  */
675
684
  export interface OwnerIdentity {
676
685
  email: string;
package/dist/org.js CHANGED
@@ -502,8 +502,8 @@ const WORKER_SHA256 = /^[0-9a-f]{64}$/;
502
502
  const WORKER_NAME_FIXED_LENGTH = 8 + 1 + '-edge'.length;
503
503
  const WORKER_NAME_MAX_LENGTH = 63;
504
504
  /**
505
- * `worker`: the entry's own repo (never the shared standard app -- entries
506
- * with `repo: null` have no Worker source of their own), an `https://`
505
+ * `worker`: the entry's own repo (entries with `repo: null` have no Worker
506
+ * source of their own), an `https://`
507
507
  * artifact URL with a lowercase-hex sha256 (never a bare path or relative
508
508
  * file -- estate DECISIONS.md, 2026-10-04, rules out any checkout or
509
509
  * bundling inside foundry), a route whose zone is the org's own domain
@@ -519,7 +519,7 @@ function validateEntryWorker(entry, hosting, domain) {
519
519
  if (!hosting.enabled)
520
520
  return `${at} requires hosting.enabled`;
521
521
  if (entry.repo === null) {
522
- return `${at} needs its own repo (repo: null is the shared standard app, not a Worker source)`;
522
+ return `${at} needs its own repo (repo: null is not a Worker source)`;
523
523
  }
524
524
  if (entry.key.length > WORKER_NAME_MAX_LENGTH - WORKER_NAME_FIXED_LENGTH) {
525
525
  return `${at} entry key is too long: the deployed Worker's name (an 8-character org hash, the key, and "-edge") must fit Cloudflare's ${WORKER_NAME_MAX_LENGTH}-character limit`;
@@ -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()) {
@@ -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
@@ -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);