@wtfalch/foundry 0.34.0 → 0.35.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.
Files changed (88) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +1 -1
  2. package/dist/bootstrap/bootstrap.js +49 -11
  3. package/dist/bootstrap/declare.d.ts +12 -5
  4. package/dist/bootstrap/declare.js +33 -18
  5. package/dist/bootstrap/versions.d.ts +20 -0
  6. package/dist/bootstrap/versions.js +46 -0
  7. package/dist/calls-sfu.js +3 -3
  8. package/dist/index.d.ts +9 -4
  9. package/dist/index.js +5 -2
  10. package/dist/migrations/0053_heartbeats_provider.sql +9 -0
  11. package/dist/migrations/0053_org_version_pin.sql +6 -0
  12. package/dist/org.d.ts +60 -14
  13. package/dist/org.js +118 -28
  14. package/dist/plan/backup-gate.d.ts +93 -0
  15. package/dist/plan/backup-gate.js +261 -0
  16. package/dist/plan/broker-entries.d.ts +9 -1
  17. package/dist/plan/broker-entries.js +20 -3
  18. package/dist/plan/build-record.d.ts +56 -0
  19. package/dist/plan/build-record.js +109 -0
  20. package/dist/plan/calls-matrix.js +3 -3
  21. package/dist/plan/cloudflared-connector.js +2 -2
  22. package/dist/plan/company-backups.d.ts +31 -0
  23. package/dist/plan/company-backups.js +101 -0
  24. package/dist/plan/deploy-health.d.ts +12 -2
  25. package/dist/plan/deploy-health.js +3 -1
  26. package/dist/plan/destinations.d.ts +73 -0
  27. package/dist/plan/destinations.js +217 -0
  28. package/dist/plan/diff.d.ts +17 -8
  29. package/dist/plan/diff.js +131 -131
  30. package/dist/plan/edge-access.d.ts +15 -0
  31. package/dist/plan/edge-access.js +21 -2
  32. package/dist/plan/executor.d.ts +166 -7
  33. package/dist/plan/executor.js +547 -106
  34. package/dist/plan/health-targets.d.ts +1 -3
  35. package/dist/plan/health-targets.js +36 -36
  36. package/dist/plan/held-key-refs.d.ts +5 -0
  37. package/dist/plan/held-key-refs.js +5 -0
  38. package/dist/plan/hostnames.d.ts +10 -0
  39. package/dist/plan/hostnames.js +14 -0
  40. package/dist/plan/index.d.ts +5 -0
  41. package/dist/plan/index.js +3 -0
  42. package/dist/plan/live.d.ts +6 -0
  43. package/dist/plan/live.js +3 -2
  44. package/dist/plan/locked-hostnames.d.ts +2 -1
  45. package/dist/plan/locked-hostnames.js +16 -3
  46. package/dist/plan/login-host.d.ts +9 -20
  47. package/dist/plan/login-host.js +10 -25
  48. package/dist/plan/mail-relay.d.ts +48 -29
  49. package/dist/plan/mail-relay.js +145 -68
  50. package/dist/plan/network-move.d.ts +20 -0
  51. package/dist/plan/network-move.js +41 -0
  52. package/dist/plan/oikos-ingress.d.ts +111 -0
  53. package/dist/plan/oikos-ingress.js +231 -0
  54. package/dist/plan/org-names.d.ts +8 -2
  55. package/dist/plan/org-names.js +57 -1
  56. package/dist/plan/own-diff.d.ts +9 -8
  57. package/dist/plan/own-diff.js +42 -34
  58. package/dist/plan/own-hostnames.d.ts +27 -0
  59. package/dist/plan/own-hostnames.js +24 -0
  60. package/dist/plan/own-limits.d.ts +198 -7
  61. package/dist/plan/own-limits.js +163 -32
  62. package/dist/plan/own-services.js +3 -1
  63. package/dist/plan/own-sfu.js +3 -2
  64. package/dist/plan/plan-apply.d.ts +47 -7
  65. package/dist/plan/plan-apply.js +184 -38
  66. package/dist/plan/platform-diff.d.ts +133 -17
  67. package/dist/plan/platform-diff.js +231 -68
  68. package/dist/plan/platform-org.d.ts +79 -10
  69. package/dist/plan/platform-org.js +93 -13
  70. package/dist/plan/platform-providers.d.ts +118 -73
  71. package/dist/plan/platform-providers.js +770 -231
  72. package/dist/plan/proxy-labels.d.ts +57 -11
  73. package/dist/plan/proxy-labels.js +94 -14
  74. package/dist/plan/run-options.d.ts +19 -0
  75. package/dist/plan/run-options.js +46 -0
  76. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  77. package/dist/plan/zitadel-login-policy.js +76 -8
  78. package/dist/platform.d.ts +32 -0
  79. package/dist/platform.js +16 -0
  80. package/dist/provision.d.ts +2 -0
  81. package/dist/provision.js +3 -1
  82. package/dist/slug.d.ts +3 -1
  83. package/dist/slug.js +25 -3
  84. package/dist/store/postgres.js +43 -1
  85. package/dist/store/schema.d.ts +36 -0
  86. package/dist/store/schema.js +4 -0
  87. package/package.json +3 -3
  88. package/versions.digests.json +11 -0
@@ -47,7 +47,7 @@ export interface BootstrapCompanyDeps {
47
47
  readers: LiveReaders;
48
48
  executor: StepExecutor;
49
49
  /** The same flags `FoundryPlanApply` takes: a step whose port the host did not build is reported blocked. */
50
- options?: Pick<FoundryPlanApplyOptions, 'ownerInviteMail' | 'broker'>;
50
+ options?: Pick<FoundryPlanApplyOptions, 'ownerInviteMail' | 'broker' | 'ownServices'>;
51
51
  }
52
52
  export interface BootstrapPlatformDeps {
53
53
  /** Foundry's tables live here, already migrated by the host; the store, the audit log and the plan all use it. */
@@ -59,13 +59,18 @@ function allowBootstrapApply(withCompany) {
59
59
  };
60
60
  }
61
61
  /**
62
- * The org steps phase two applies: the sign-in org and its domain, the owner
62
+ * The org steps phase two applies: the owner's own sign-in server and its zone,
63
+ * the sign-in org and its domain, the owner
63
64
  * (whose user id Boule's `AUTHZ_FIRST_OWNER` carries), Boule's sign-in client,
64
65
  * and Boule with its database. Everything else the company declares (chat,
65
66
  * mail, Boule's broker entry, which waits for the org's mail domain, ...) is
66
67
  * for a later, ordinary org apply.
67
68
  */
68
69
  const COMPANY_SLICE_STEPS = new Set([
70
+ // The owner's own sign-in server, before anyone can sign in. Own mail, files,
71
+ // jobs and sfu follow after sign-in, as for every company.
72
+ 'cloudflare:zone',
73
+ 'coolify:own-auth',
69
74
  'zitadel:issuer',
70
75
  'zitadel:registration-closed',
71
76
  'zitadel:domain',
@@ -77,8 +82,10 @@ const companySliceOnly = {
77
82
  step: (action) => COMPANY_SLICE_STEPS.has(`${action.system}:${action.kind}`) ||
78
83
  COMPANY_SLICE_STEPS.has(`${action.system}:${action.kind}:${action.key}`),
79
84
  // `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}:`)),
85
+ // `own-auth` is the unmanaged key `planOwnAuth` uses for a blocked sign-in server.
86
+ blocked: (key) => key === 'own-auth' ||
87
+ (key !== 'coolify:boule:mail' &&
88
+ [...COMPANY_SLICE_STEPS].some((step) => key === step || key.startsWith(`${step}:`))),
82
89
  };
83
90
  /** 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
91
  const COMPANY_NOT_FINISHED = "the company's own slice did not finish, so Archon has no people-store keys yet; run onboarding again";
@@ -118,6 +125,10 @@ function trackedOrgExecutor(inner, progress, redactor) {
118
125
  }
119
126
  },
120
127
  ...(inner.ownEdgePorts ? { ownEdgePorts: () => inner.ownEdgePorts?.() ?? {} } : {}),
128
+ // The own sign-in step plans without a held Lettermint key only when the executor can mint one.
129
+ ...(inner.ownMailKeyMintable
130
+ ? { ownMailKeyMintable: () => inner.ownMailKeyMintable?.() ?? false }
131
+ : {}),
121
132
  };
122
133
  }
123
134
  /** The audit log with every free-text field run through the redactor before it is stored. */
@@ -334,10 +345,14 @@ export async function bootstrapPlatform(input, deps) {
334
345
  return result;
335
346
  },
336
347
  ...(inner.blockedSteps
337
- ? { blockedSteps: (platform, ids) => inner.blockedSteps?.(platform, ids) ?? {} }
348
+ ? {
349
+ blockedSteps: (platform, ids, owner) => inner.blockedSteps?.(platform, ids, owner) ?? {},
350
+ }
338
351
  : {}),
339
352
  ...(inner.driftedSteps
340
- ? { driftedSteps: (platform, ids) => inner.driftedSteps?.(platform, ids) ?? {} }
353
+ ? {
354
+ driftedSteps: (platform, ids, owner) => inner.driftedSteps?.(platform, ids, owner) ?? {},
355
+ }
341
356
  : {}),
342
357
  };
343
358
  let orgProgress = { started: 0, completed: 0 };
@@ -392,10 +407,11 @@ export async function bootstrapPlatform(input, deps) {
392
407
  const blockedRaw = (driven.failed ? [] : (driven.lastPlan?.unmanaged ?? []))
393
408
  .filter((item) => item.key !== undefined)
394
409
  .map((item) => ({ step: item.key, message: item.description }));
410
+ const platformBlockedCount = blockedRaw.length;
395
411
  if (companyDeps && !failed) {
396
412
  // Phase two: the platform's own org. Its Boule needs the platform's
397
413
  // sign-in, and Archon's people-store keys need that Boule.
398
- const company = await driveApply({
414
+ const driveCompany = (label) => driveApply({
399
415
  plan: () => planApply.plan(COMPANY_INCLUDED_ORG_ID),
400
416
  apply: (plan, stepProgress) => {
401
417
  orgProgress = stepProgress;
@@ -406,27 +422,30 @@ export async function bootstrapPlatform(input, deps) {
406
422
  phase: 'plan',
407
423
  system: roundSystem(plan),
408
424
  round,
409
- message: `company round ${round}: ${plan.steps.length} step(s) planned`,
425
+ message: `${label}company round ${round}: ${plan.steps.length} step(s) planned`,
410
426
  steps: plan.steps.map((s) => s.description),
411
427
  }),
412
428
  onApplied: (round, plan) => emit({
413
429
  phase: 'apply',
414
430
  system: roundSystem(plan),
415
431
  round,
416
- message: `company round ${round}: applied`,
432
+ message: `${label}company round ${round}: applied`,
417
433
  steps: plan.steps.map((s) => s.description),
418
434
  }),
419
435
  });
436
+ const company = await driveCompany('');
420
437
  applied.push(...company.applied);
421
438
  if (company.failed) {
422
439
  failed = company.failed;
423
440
  blockedRaw.push({ step: 'company', message: COMPANY_NOT_FINISHED });
424
441
  }
425
442
  else {
443
+ const companyBlockedStart = blockedRaw.length;
426
444
  for (const item of company.lastPlan?.unmanaged ?? []) {
427
445
  if (item.key !== undefined)
428
446
  blockedRaw.push({ step: item.key, message: item.description });
429
447
  }
448
+ const companyBlockedCount = blockedRaw.length - companyBlockedStart;
430
449
  // Boule's broker entry needs the org's mail domain, which only the org's mail step
431
450
  // records, so it is outside this slice. Until it is applied nobody can sign in to Boule:
432
451
  // say so, and never report the onboarding complete.
@@ -444,12 +463,31 @@ export async function bootstrapPlatform(input, deps) {
444
463
  failed = again.failed;
445
464
  lastPlan = again.lastPlan;
446
465
  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 });
466
+ // Archon's registration put the first entry on the owner's broker. Only now can the
467
+ // owner's sign-in mint its login client and start that broker, so its plan runs once more.
468
+ const signIn = await driveCompany('after Archon: ');
469
+ applied.push(...signIn.applied);
470
+ if (signIn.failed) {
471
+ failed = signIn.failed;
472
+ blockedRaw.push({ step: 'company', message: COMPANY_NOT_FINISHED });
473
+ }
474
+ else {
475
+ const fresh = [];
476
+ for (const item of signIn.lastPlan?.unmanaged ?? []) {
477
+ if (item.key !== undefined)
478
+ fresh.push({ step: item.key, message: item.description });
450
479
  }
480
+ blockedRaw.splice(companyBlockedStart, companyBlockedCount, ...fresh);
451
481
  }
452
482
  }
483
+ if (!failed) {
484
+ // Archon was blocked on the owner's sign-in in phase one; this plan is the current truth.
485
+ blockedRaw.splice(0, platformBlockedCount);
486
+ const platformBlocked = (again.lastPlan?.unmanaged ?? [])
487
+ .filter((item) => item.key !== undefined)
488
+ .map((item) => ({ step: item.key, message: item.description }));
489
+ blockedRaw.unshift(...platformBlocked);
490
+ }
453
491
  }
454
492
  }
455
493
  const blocked = blockedRaw.map((item) => ({
@@ -38,6 +38,11 @@ export interface BootstrapCompany {
38
38
  name: string;
39
39
  slug: string;
40
40
  apex: string;
41
+ /**
42
+ * The owner company's own domain: absent means `apex`, `null` means none (it
43
+ * then runs on oikos.host). `apex` stays the platform's.
44
+ */
45
+ domain?: string | null;
41
46
  }
42
47
  export interface BootstrapOwner {
43
48
  givenName: string;
@@ -54,13 +59,15 @@ export interface BootstrapOwner {
54
59
  */
55
60
  export declare function ownerIdentity(owner: BootstrapOwner): OwnerIdentity;
56
61
  /**
57
- * Company Included's declared org: the platform's owner, tier 3 on the
58
- * platform's own apex from the start (#512) -- it runs on that domain with
59
- * hosted mail, not a level-1 org whose domain gets set by hand afterward.
62
+ * Company Included's declared org: the platform's owner, a company like any
63
+ * other (`services.own`: its own sign-in, mail, files and jobs). Tier 3 on the
64
+ * platform's apex from the start (#512) -- it runs on that domain with
65
+ * hosted mail, not a level-1 org whose domain gets set by hand afterward --
66
+ * unless `company.domain` is `null`, which runs it on oikos.host.
60
67
  * `issuer`/`calls`/`site` go on the domain too, the same tier-3 defaults
61
68
  * archon#388 gives every company declared there. `calls` and `site` stay
62
- * disabled (calls is not in v1; no site is declared), so `usesDomain` is
63
- * inert on both until one is switched on. Chat stays off the domain: its
69
+ * disabled (calls is not in v1; no site is declared). Without a domain
70
+ * `calls.usesDomain` is off, as `validateCalls` requires. Chat stays off the domain: its
64
71
  * re-key onto a per-org homeserver is not built yet (#463). The org's own
65
72
  * sign-in still never collides with the shared ZITADEL instance's host --
66
73
  * `loginHostnameFor` (#488) falls back to `<slug>-auth.oikos.host` whenever
@@ -36,10 +36,11 @@ export const DEFAULT_MAIL_IMAGE_TAG = VERSIONS.stalwart;
36
36
  */
37
37
  export const DEFAULT_HC_IMAGE_TAG = VERSIONS.hc;
38
38
  /**
39
- * Every platform service except `sfu` is enabled, like the other steps: a
40
- * service whose dependencies the host did not supply is reported blocked,
41
- * not planned. `sfu` stays off because it needs a held SSH key and host
42
- * fingerprint, a separate human step (issue 170). `edge` is on because every
39
+ * Every platform service is enabled except `auth`, `mail`, `files`, `jobs`
40
+ * and `sfu`: the owner company runs its own copies of those like any
41
+ * company (`validatePlatform` refuses them on), so the platform keeps only
42
+ * what no company owns. A service whose dependencies the host did not supply
43
+ * is reported blocked, not planned. `edge` is on because every
43
44
  * platform DNS record is written through it (F4.dns); its own Cloudflare API
44
45
  * token is a held key (`cloudflare:edge:apiToken`), and until a human holds
45
46
  * it the planner reports edge blocked, and auth, mail and archon with it.
@@ -47,16 +48,23 @@ export const DEFAULT_HC_IMAGE_TAG = VERSIONS.hc;
47
48
  * the same as mail and archon.
48
49
  */
49
50
  const SERVICES_ENABLED_BY_DEFAULT = new Set([
50
- 'auth',
51
- 'mail',
52
- 'files',
53
51
  'ai',
54
- 'jobs',
55
52
  'integrations',
56
53
  'archon',
57
54
  'edge',
58
55
  'hc',
59
56
  ]);
57
+ /**
58
+ * The services every company deploys its own copy of. Their platform entries
59
+ * are off but still carry the release the own steps deploy
60
+ * (`serviceGitRefsOf`); with an empty one `own-auth` is blocked.
61
+ */
62
+ const OWN_SERVICE_KEYS = new Set([
63
+ 'auth',
64
+ 'mail',
65
+ 'files',
66
+ 'jobs',
67
+ ]);
60
68
  /**
61
69
  * The owner as `OwnerIdentity`, trimmed. Throws a plain error naming the
62
70
  * field when one is empty or an address is not an address. The work address
@@ -75,33 +83,36 @@ export function ownerIdentity(owner) {
75
83
  return identity;
76
84
  }
77
85
  /**
78
- * Company Included's declared org: the platform's owner, tier 3 on the
79
- * platform's own apex from the start (#512) -- it runs on that domain with
80
- * hosted mail, not a level-1 org whose domain gets set by hand afterward.
86
+ * Company Included's declared org: the platform's owner, a company like any
87
+ * other (`services.own`: its own sign-in, mail, files and jobs). Tier 3 on the
88
+ * platform's apex from the start (#512) -- it runs on that domain with
89
+ * hosted mail, not a level-1 org whose domain gets set by hand afterward --
90
+ * unless `company.domain` is `null`, which runs it on oikos.host.
81
91
  * `issuer`/`calls`/`site` go on the domain too, the same tier-3 defaults
82
92
  * archon#388 gives every company declared there. `calls` and `site` stay
83
- * disabled (calls is not in v1; no site is declared), so `usesDomain` is
84
- * inert on both until one is switched on. Chat stays off the domain: its
93
+ * disabled (calls is not in v1; no site is declared). Without a domain
94
+ * `calls.usesDomain` is off, as `validateCalls` requires. Chat stays off the domain: its
85
95
  * re-key onto a per-org homeserver is not built yet (#463). The org's own
86
96
  * sign-in still never collides with the shared ZITADEL instance's host --
87
97
  * `loginHostnameFor` (#488) falls back to `<slug>-auth.oikos.host` whenever
88
98
  * `auth.<domain>` would equal the platform's own auth hostname.
89
99
  */
90
100
  export function declaredCompanyOrg(company, owner) {
101
+ const domain = company.domain === undefined ? company.apex : company.domain;
91
102
  return {
92
103
  id: COMPANY_INCLUDED_ORG_ID,
93
104
  slug: company.slug,
94
105
  displayName: company.name,
95
- domain: company.apex,
106
+ domain,
96
107
  mail: {
97
108
  mode: 'hosted',
98
- usesDomain: true,
109
+ usesDomain: domain !== null,
99
110
  ownerMailboxAddress: null,
100
111
  noReplyLocalPart: 'no-reply',
101
112
  },
102
113
  chat: { enabled: true, usesDomain: false },
103
- calls: { enabled: false, usesDomain: true },
104
- issuer: { usesDomain: true },
114
+ calls: { enabled: false, usesDomain: domain !== null },
115
+ issuer: { usesDomain: domain !== null },
105
116
  site: { enabled: false, usesDomain: true },
106
117
  hosting: {
107
118
  enabled: false,
@@ -119,6 +130,7 @@ export function declaredCompanyOrg(company, owner) {
119
130
  buyer: { legalName: null, orgNumber: null, address: null, vatRegistered: null },
120
131
  bespokeAppRepo: null,
121
132
  adoptedApp: null,
133
+ services: { own: true },
122
134
  owners: [ownerIdentity(owner)],
123
135
  test: false,
124
136
  };
@@ -134,7 +146,7 @@ export function declaredPlatformFor(apex, serverIpv4) {
134
146
  services[key] = {
135
147
  enabled,
136
148
  hostname: defaultPlatformHostname(key, apex),
137
- gitRef: !enabled
149
+ gitRef: !enabled && !OWN_SERVICE_KEYS.has(key)
138
150
  ? ''
139
151
  : key === 'mail'
140
152
  ? DEFAULT_MAIL_IMAGE_TAG
@@ -148,9 +160,12 @@ export function declaredPlatformFor(apex, serverIpv4) {
148
160
  apex,
149
161
  ...(serverIpv4 ? { serverIpv4 } : {}),
150
162
  ...services,
163
+ // The platform runs no mail server, but every company's mail goes through the shared relay.
164
+ mail: { ...services.mail, relay: true },
151
165
  // Off, like `sfu`: the pin is an Archon release tag and digest, which exist
152
166
  // only after Archon runs. Declared on with no pin, it would be reported
153
167
  // blocked and the bootstrap would never be complete.
154
168
  uptime: { enabled: false, tag: '', sha256: '' },
169
+ heartbeatsProvider: 'self-hosted',
155
170
  };
156
171
  }
@@ -20,6 +20,24 @@ export interface PinnedVersions {
20
20
  }
21
21
  export declare function loadVersions(): PinnedVersions;
22
22
  export declare const VERSIONS: PinnedVersions;
23
+ /**
24
+ * The multi-arch index digest of each tag in `versions.json`, in
25
+ * `versions.digests.json` beside it. Kept apart so every tag comparison
26
+ * (applied refs, version-change lines) stays on the tag. Validated on read:
27
+ * a malformed digest, or a key of `versions.json` without one, throws.
28
+ */
29
+ export declare function loadDigests(): PinnedVersions;
30
+ export declare const DIGESTS: PinnedVersions;
31
+ /**
32
+ * `<tag>@<digest>` for an image string handed to Coolify or a Dockerfile.
33
+ * Only the image string uses it; the recorded applied ref stays the tag.
34
+ */
35
+ export declare function pinnedRef(key: keyof PinnedVersions): string;
36
+ /**
37
+ * A declared tag that equals the pinned one gets its digest; any other
38
+ * declared tag (an override) is passed through unchanged.
39
+ */
40
+ export declare function pinnedTagFor(key: keyof PinnedVersions, tag: string): string;
23
41
  /**
24
42
  * Why an image tag cannot be used, or `null`. #248: generalised out of what
25
43
  * was `platform-providers.ts`'s `mailImageTagBlocked`, the one guard
@@ -28,3 +46,5 @@ export declare const VERSIONS: PinnedVersions;
28
46
  * `"ZITADEL"`, `"LiveKit"`, ...); `example` is a real pinned tag to suggest.
29
47
  */
30
48
  export declare function floatingTagBlocked(label: string, tag: string, example: string): string | null;
49
+ /** {@link floatingTagBlocked} for a git ref: it also refuses `main`, `master` and `HEAD`. */
50
+ export declare function floatingRefBlocked(label: string, ref: string, example: string): string | null;
@@ -10,6 +10,43 @@ export function loadVersions() {
10
10
  return JSON.parse(readFileSync(VERSIONS_PATH, 'utf8'));
11
11
  }
12
12
  export const VERSIONS = loadVersions();
13
+ const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
14
+ const DIGESTS_PATH = new URL('../../versions.digests.json', import.meta.url);
15
+ /**
16
+ * The multi-arch index digest of each tag in `versions.json`, in
17
+ * `versions.digests.json` beside it. Kept apart so every tag comparison
18
+ * (applied refs, version-change lines) stays on the tag. Validated on read:
19
+ * a malformed digest, or a key of `versions.json` without one, throws.
20
+ */
21
+ export function loadDigests() {
22
+ const raw = JSON.parse(readFileSync(DIGESTS_PATH, 'utf8'));
23
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
24
+ throw new Error('versions.digests.json must be a JSON object');
25
+ }
26
+ const record = raw;
27
+ for (const key of Object.keys(loadVersions())) {
28
+ const value = record[key];
29
+ if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
30
+ throw new Error(`versions.digests.json: "${key}" must be a sha256:<64 hex> digest`);
31
+ }
32
+ }
33
+ return record;
34
+ }
35
+ export const DIGESTS = loadDigests();
36
+ /**
37
+ * `<tag>@<digest>` for an image string handed to Coolify or a Dockerfile.
38
+ * Only the image string uses it; the recorded applied ref stays the tag.
39
+ */
40
+ export function pinnedRef(key) {
41
+ return `${VERSIONS[key]}@${DIGESTS[key]}`;
42
+ }
43
+ /**
44
+ * A declared tag that equals the pinned one gets its digest; any other
45
+ * declared tag (an override) is passed through unchanged.
46
+ */
47
+ export function pinnedTagFor(key, tag) {
48
+ return tag === VERSIONS[key] ? pinnedRef(key) : tag;
49
+ }
13
50
  /**
14
51
  * Tag spellings that resolve to whatever the registry currently has, not a
15
52
  * fixed image -- a re-apply of the same declaration can silently run a
@@ -41,3 +78,12 @@ export function floatingTagBlocked(label, tag, example) {
41
78
  }
42
79
  return null;
43
80
  }
81
+ /** Branch names that move, refused for a git ref on top of the floating image tags. */
82
+ const FLOATING_GIT_REFS = new Set(['main', 'master', 'head']);
83
+ /** {@link floatingTagBlocked} for a git ref: it also refuses `main`, `master` and `HEAD`. */
84
+ export function floatingRefBlocked(label, ref, example) {
85
+ if (FLOATING_GIT_REFS.has(ref.trim().toLowerCase())) {
86
+ return `the ${label} image tag "${ref}" floats; declare a pinned release (for example ${example})`;
87
+ }
88
+ return floatingTagBlocked(label, ref, example);
89
+ }
package/dist/calls-sfu.js CHANGED
@@ -52,7 +52,7 @@
52
52
  * `calls-sfu-deploy-support.ts`'s `fileWriteMode`), never logged, scrubbed
53
53
  * from any error `calls-sfu-deploy.mjs` prints.
54
54
  */
55
- import { VERSIONS } from './bootstrap/versions.js';
55
+ import { VERSIONS, pinnedRef } from './bootstrap/versions.js';
56
56
  import { SFU_SLOT_MAX } from './org.js';
57
57
  // ---------------------------------------------------------------------------
58
58
  // The guard
@@ -214,7 +214,7 @@ const CLOUDFLARED_METRICS_ADDRESS = '127.0.0.1:20241';
214
214
  export function dockerComposeYaml() {
215
215
  return `services:
216
216
  livekit:
217
- image: ${LIVEKIT_IMAGE_REF}
217
+ image: livekit/livekit-server:${pinnedRef('livekit')}
218
218
  restart: unless-stopped
219
219
  network_mode: host
220
220
  volumes:
@@ -227,7 +227,7 @@ export function dockerComposeYaml() {
227
227
  retries: 3
228
228
  start_period: 30s
229
229
  cloudflared:
230
- image: ${CLOUDFLARED_IMAGE_REF}
230
+ image: cloudflare/cloudflared:${pinnedRef('cloudflared')}
231
231
  restart: unless-stopped
232
232
  network_mode: host
233
233
  env_file:
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EntryGate, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceSchema, ServicesConfig, } from './org.js';
2
- export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, appEntry, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateSite, } from './org.js';
3
- export type { DeclaredPlatform, PlatformService, PlatformServiceKey, PlatformSfuService, } from './platform.js';
4
- export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
1
+ export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EntryGate, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceSchema, ServicesConfig, VersionPin, VersionPinPart, } from './org.js';
2
+ export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateVersionPin, validateSite, } from './org.js';
3
+ export type { DeclaredPlatform, PlatformService, PlatformServiceKey, PlatformSfuService, HeartbeatsProvider, } from './platform.js';
4
+ export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, validatePlatform, } from './platform.js';
5
5
  import { type PinnedVersions } from './bootstrap/versions.js';
6
6
  export type { PinnedVersions };
7
7
  export declare const PINNED_VERSIONS: Readonly<PinnedVersions>;
@@ -19,7 +19,10 @@ 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
21
  export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, StepSystem, PlanDetail, OrgPostgresPlan, PlatformPlanDetail, MailRoute, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, FoundryApplyOptions, UnconfirmedUpgrade, } from './plan/index.js';
22
+ export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINATIONS, DestinationCapacityError, PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, placed, placedOnPlatform, validateNetworkName, } from './plan/index.js';
22
23
  export { AdoptRefusedError } from './plan/index.js';
24
+ export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './plan/index.js';
25
+ export type { BackupGatePorts } from './plan/index.js';
23
26
  export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
24
27
  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
28
  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';
@@ -35,6 +38,8 @@ export type { PlanOwnSfuOptions, SfuAppDeps, SfuAppInput } from './plan/index.js
35
38
  export { OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, planOwnSfu, provisionSfuApp, sfuConfig, sfuPortsMappings, } from './plan/index.js';
36
39
  export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, LoginHostBinding, LoginHostBindingsPort, LoginHostDiscoveryPort, } from './plan/index.js';
37
40
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
41
+ export { NO_DOMAIN_ZONE, ownHostnames } from './plan/index.js';
42
+ export type { OwnHostnames } from './plan/index.js';
38
43
  export type { EnsureSignInOrgOptions } from './plan/index.js';
39
44
  export { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ensureSignInOrg, registerSignInDomain, signInOrgDomain, } from './plan/index.js';
40
45
  export { HttpJobsSchedulerClient } from './plan/index.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, appEntry, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateSite, } from './org.js';
2
- export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
1
+ export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateVersionPin, validateSite, } from './org.js';
2
+ export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, validatePlatform, } from './platform.js';
3
3
  // archon#273: the pinned platform service versions the planner deploys, so a
4
4
  // host can show declared against running. Readonly: it is the planner's own
5
5
  // object, not a copy.
@@ -11,7 +11,9 @@ export { StaleLeaseError } from './store.js';
11
11
  export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
12
12
  export { createPostgresAuditLog } from './store/audit.js';
13
13
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
14
+ export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINATIONS, DestinationCapacityError, PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, placed, placedOnPlatform, validateNetworkName, } from './plan/index.js';
14
15
  export { AdoptRefusedError } from './plan/index.js';
16
+ export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './plan/index.js';
15
17
  export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
16
18
  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
19
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, } from './plan/index.js';
@@ -20,6 +22,7 @@ export { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICE
20
22
  export { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY, OWN_JOBS_BLOCKED_KEY, OWN_MAIL_BLOCKED_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, provisionFilesHost, provisionJobsHost, provisionMailServer, } from './plan/index.js';
21
23
  export { OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, planOwnSfu, provisionSfuApp, sfuConfig, sfuPortsMappings, } from './plan/index.js';
22
24
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
25
+ export { NO_DOMAIN_ZONE, ownHostnames } from './plan/index.js';
23
26
  export { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ensureSignInOrg, registerSignInDomain, signInOrgDomain, } from './plan/index.js';
24
27
  export { HttpJobsSchedulerClient } from './plan/index.js';
25
28
  export { healthTargets, platformHealthTargets } from './plan/index.js';
@@ -0,0 +1,9 @@
1
+ -- @wtfalch/foundry: foundry#544 -- which Healthchecks instance manages the
2
+ -- platform's heartbeat checks, decoupled from whether foundry also deploys
3
+ -- its own self-hosted server (`hc_enabled`, foundry#540). 'self-hosted' is
4
+ -- the default and today's only behaviour; 'hosted' needs no self-hosted
5
+ -- server applied at all. Mirrors `src/store/schema.ts`'s
6
+ -- `foundryDeclaredPlatform` table addition.
7
+
8
+ alter table foundry_declared_platform
9
+ add column if not exists heartbeats_provider text not null default 'self-hosted';
@@ -0,0 +1,6 @@
1
+ -- @wtfalch/foundry: a company's version hold. `version_pin` is a json object of
2
+ -- part to git ref or image tag (auth, mail, files, jobs, sfu, calls); null
3
+ -- follows the platform. Additive. Mirrors src/store/schema.ts's `declaredOrgs`.
4
+
5
+ alter table foundry_declared_orgs
6
+ add column if not exists version_pin jsonb;
package/dist/org.d.ts CHANGED
@@ -316,7 +316,14 @@ export interface HostingEntry {
316
316
  hostnames: string[];
317
317
  /** A Postgres database beside the app. At most one entry per org. */
318
318
  database: boolean;
319
- /** Deploy on every push to `branch`. Default: `true`. */
319
+ /**
320
+ * The Linux capabilities of the entry's app container. Absent or `'restricted'`: all dropped, then only the
321
+ * few a web server or node entrypoint needs added back. `'standard'`: Docker's defaults minus raw sockets,
322
+ * device nodes, file capabilities, chroot and audit writes. A fallback for an image that will not start
323
+ * restricted.
324
+ */
325
+ capabilities?: 'restricted' | 'standard';
326
+ /** Deploy on every push to `branch`. A host should declare `false`; foundry creates every other app with auto-deploy off. */
320
327
  autoDeploy: boolean;
321
328
  /**
322
329
  * Cloudflare Access gates on this entry's own `hostnames`. Empty means no
@@ -486,13 +493,11 @@ export declare function appEntry(hosting: HostingConfig): HostingEntry | undefin
486
493
  * sends from. Each is read from `StepExecutionContext.recordedIds()` in
487
494
  * `executor.ts`'s `pushAppEnv`, never minted again there.
488
495
  */
489
- export type AppEnvSource = 'filesOrganisationId' | 'aiOrganisationId' | 'mailDomain' | 'sharedSecret' | 'platformMailKey';
496
+ export type AppEnvSource = 'filesOrganisationId' | 'aiOrganisationId' | 'mailDomain' | 'sharedSecret';
490
497
  /**
491
- * Two sources are secrets. `sharedSecret` is {@link EnvSharedSecret}.
492
- * `platformMailKey` is the platform's held Lettermint key, handed over as the
493
- * value of the declared env name (the platform's own apps call it
494
- * `LETTERMINT_API_TOKEN`). Any hosting entry of any customer org may name it
495
- * (William, 2026-10-04): mail is Lettermint, and the key is the platform's.
498
+ * `sharedSecret` is the one secret source: {@link EnvSharedSecret}. The
499
+ * platform's Lettermint key is not a source any more (2026-10-11): each
500
+ * company uses its own mail key.
496
501
  */
497
502
  export declare const APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
498
503
  /** The sources whose value is a secret: resolved at apply time, never recorded. */
@@ -516,6 +521,29 @@ export declare function validateHosting(hosting: HostingConfig, names?: {
516
521
  locked: ReadonlySet<string>;
517
522
  }): string | null;
518
523
  export declare function validateDomain(domain: string): string | null;
524
+ /**
525
+ * A broker entry name foundry writes itself: Archon's, and any company's Boule.
526
+ * A hosting entry may not declare that it replaces one.
527
+ */
528
+ export declare function isFoundryOwnedBrokerEntryName(name: string): boolean;
529
+ /**
530
+ * `null` when `domain` may be claimed by company `orgId`, given the platform's
531
+ * apex and owner company and every other company's declared domain; otherwise
532
+ * the reason it may not. The platform's apex and anything under it belong to
533
+ * the owner company alone, and no company takes a parent or subdomain of
534
+ * another's, since Cloudflare would resolve both to one zone.
535
+ */
536
+ export declare function validateDomainClaim(domain: string, claim: {
537
+ orgId: string;
538
+ platform: {
539
+ apex: string;
540
+ ownerOrgId: string;
541
+ } | null;
542
+ others: readonly {
543
+ id: string;
544
+ domain: string | null;
545
+ }[];
546
+ }): string | null;
519
547
  /**
520
548
  * `null` when `site` is valid; otherwise the reason it is not (#101).
521
549
  *
@@ -526,11 +554,9 @@ export declare function validateDomain(domain: string): string | null;
526
554
  * and cms there (`site`), never both -- both would otherwise fight over the
527
555
  * same hostname (https://github.com/wtfalch/foundry/issues/99).
528
556
  *
529
- * - `site.enabled` requires `domain` to be set -- a site with nowhere to
530
- * live is not a site.
531
- * - `site.enabled` requires `site.usesDomain` -- unlike mail, chat, calls
532
- * and the issuer, the site has no meaning anywhere but the org's own
533
- * domain, so turning it on always means the domain opt-in too.
557
+ * - `site.enabled` with a `domain` requires `site.usesDomain`: the site then
558
+ * lives on the domain. Without a `domain` it lives on `<slug>.oikos.host`
559
+ * and `site.usesDomain` must be off.
534
560
  * - `site.enabled` and `hosting.enabled` cannot both be on.
535
561
  * - `site.enabled` refuses an `issuerApps` entry named `cms`: the fixed cms
536
562
  * client (#139, `plan/diff.ts`'s `planCmsClient`) already owns the
@@ -548,7 +574,25 @@ export declare function validateSite(org: DeclaredOrg): string | null;
548
574
  * reason to pin at all.
549
575
  */
550
576
  export declare function validateCalls(org: DeclaredOrg): string | null;
551
- /** A company with a domain runs its own sign-in, mail, files, jobs and calls SFU in its own containers. */
577
+ /** The parts of a company that can be held on their own version. */
578
+ export declare const VERSION_PIN_PARTS: readonly ["auth", "mail", "files", "jobs", "sfu", "calls"];
579
+ export type VersionPinPart = (typeof VERSION_PIN_PARTS)[number];
580
+ /**
581
+ * A part held at a git ref (auth, files, jobs) or an image tag (mail, sfu, calls). Absent part: the platform's value.
582
+ * `calls` is the Synapse tag of the calls stack. It is used when the stack is created; a running stack is not moved
583
+ * (nothing patches the image tag of an existing app, see the Synapse create in `plan/executor.ts`).
584
+ */
585
+ export type VersionPin = Partial<Record<VersionPinPart, string>>;
586
+ /**
587
+ * `versionPin` holds parts of a company on an older release while others move.
588
+ * Every value is a fixed ref or tag: a floating one would defeat the hold.
589
+ * Also run on a stored value when it is read, so a bad row is not trusted.
590
+ */
591
+ export declare function validateVersionPin(org: Pick<DeclaredOrg, 'versionPin'>): string | null;
592
+ /**
593
+ * Every company runs its own sign-in, mail, files, jobs and calls SFU in its own containers, the platform's owner company too.
594
+ * A company without a domain runs them on oikos.host.
595
+ */
552
596
  export interface ServicesConfig {
553
597
  /** true: own copies of the services. false (default): the platform's shared ones. */
554
598
  own: boolean;
@@ -560,7 +604,7 @@ export interface ServicesConfig {
560
604
  sfuSlot?: number | null;
561
605
  }
562
606
  export declare const SFU_SLOT_MAX = 20;
563
- /** The only reader of `services.own`: an org without `services` runs the platform's shared services. */
607
+ /** The only reader of `services.own`. Every company runs its own services, the platform's owner company too; a company without a domain runs them on oikos.host. */
564
608
  export declare function ownsServices(org: Pick<DeclaredOrg, 'services'>): boolean;
565
609
  /** The pure part of the `services` rules; that no two orgs share an `sfuSlot` is the store's check. */
566
610
  export declare function validateServices(org: DeclaredOrg): string | null;
@@ -840,6 +884,8 @@ export interface DeclaredOrg {
840
884
  hosting: HostingConfig;
841
885
  /** Absent means `{ own: false }`. Optional so every existing literal and test fixture compiles unchanged, as `calls.versionPin` and `MailConfig.mailApps` do. */
842
886
  services?: ServicesConfig;
887
+ /** Parts of this company held on their own version. Absent or null follows the platform. Ignored for the owner company. */
888
+ versionPin?: VersionPin | null;
843
889
  sso: SsoConfig;
844
890
  guests: GuestsConfig;
845
891
  solo: SoloConfig;