@wtfalch/foundry 0.16.16 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/apply.d.ts +22 -1
  2. package/dist/bootstrap/bootstrap.d.ts +6 -1
  3. package/dist/bootstrap/bootstrap.js +26 -3
  4. package/dist/bootstrap/declare.d.ts +11 -1
  5. package/dist/bootstrap/declare.js +12 -2
  6. package/dist/index.d.ts +9 -4
  7. package/dist/index.js +7 -0
  8. package/dist/migrations/0038_calls_version_pin.sql +8 -0
  9. package/dist/migrations/0039_destroy_record.sql +7 -0
  10. package/dist/migrations/0040_company_included_uuid.sql +72 -0
  11. package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
  12. package/dist/migrations/0042_mutation_leases.sql +12 -0
  13. package/dist/org.d.ts +54 -0
  14. package/dist/org.js +43 -1
  15. package/dist/plan/adopt.d.ts +76 -0
  16. package/dist/plan/adopt.js +184 -0
  17. package/dist/plan/agora.d.ts +21 -0
  18. package/dist/plan/agora.js +37 -0
  19. package/dist/plan/broker-entries.d.ts +9 -1
  20. package/dist/plan/broker-entries.js +13 -5
  21. package/dist/plan/chat-matrix.d.ts +1 -1
  22. package/dist/plan/chat-matrix.js +11 -3
  23. package/dist/plan/core-files.d.ts +25 -0
  24. package/dist/plan/core-files.js +25 -0
  25. package/dist/plan/diff.d.ts +11 -3
  26. package/dist/plan/diff.js +27 -62
  27. package/dist/plan/entry-ai-key.d.ts +3 -3
  28. package/dist/plan/entry-files-key.d.ts +3 -3
  29. package/dist/plan/executor.d.ts +57 -23
  30. package/dist/plan/executor.js +134 -50
  31. package/dist/plan/index.d.ts +4 -0
  32. package/dist/plan/index.js +2 -0
  33. package/dist/plan/live.d.ts +5 -0
  34. package/dist/plan/login-host.d.ts +1 -1
  35. package/dist/plan/login-host.js +6 -5
  36. package/dist/plan/plan-apply.d.ts +139 -6
  37. package/dist/plan/plan-apply.js +546 -76
  38. package/dist/plan/platform-diff.d.ts +14 -0
  39. package/dist/plan/platform-diff.js +16 -1
  40. package/dist/plan/platform-org.d.ts +16 -0
  41. package/dist/plan/platform-org.js +17 -1
  42. package/dist/plan/platform-providers.d.ts +1 -1
  43. package/dist/plan/platform-providers.js +73 -11
  44. package/dist/plan/providers-readers.d.ts +2 -0
  45. package/dist/plan/providers-readers.js +4 -0
  46. package/dist/plan/rollout.d.ts +52 -0
  47. package/dist/plan/rollout.js +37 -0
  48. package/dist/plan/step-system.d.ts +22 -0
  49. package/dist/plan/step-system.js +37 -0
  50. package/dist/plan.d.ts +3 -0
  51. package/dist/state.d.ts +13 -0
  52. package/dist/store/audit.js +15 -1
  53. package/dist/store/postgres.d.ts +1 -1
  54. package/dist/store/postgres.js +127 -14
  55. package/dist/store/schema.d.ts +134 -5
  56. package/dist/store/schema.js +12 -1
  57. package/dist/store.d.ts +47 -4
  58. package/dist/store.js +9 -1
  59. package/package.json +1 -1
package/dist/apply.d.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import type { OrgId } from './org.js';
2
2
  import type { Plan } from './plan.js';
3
3
  import type { AppliedState } from './state.js';
4
+ /** One destroy step starting, finishing or failing; `index` counts from 1 up to `total`. */
5
+ export interface DestroyProgress {
6
+ step: string;
7
+ index: number;
8
+ total: number;
9
+ status: 'started' | 'finished' | 'failed';
10
+ error?: string;
11
+ }
4
12
  /**
5
13
  * The plan/apply seam https://github.com/wtfalch/foundry/issues/19
6
14
  * implements.
@@ -37,5 +45,18 @@ export interface PlanApply {
37
45
  * declared org in place with whatever remains recorded, so a retried
38
46
  * `destroy` only tears down what's left.
39
47
  */
40
- destroy(id: OrgId, confirmedOrgName: string): Promise<void>;
48
+ destroy(id: OrgId, confirmedOrgName: string, options?: {
49
+ onProgress?: (event: DestroyProgress) => void;
50
+ }): Promise<void>;
51
+ /**
52
+ * The steps `destroy` will run, in order, for a test org (same refusal as
53
+ * `destroy`: any other org throws). Read-only. A stopped destroy's
54
+ * finished and failed steps are `AppliedState.destroy`. `steps` are exactly
55
+ * what `destroy` runs, in order; `kept` lists the adopted resources it
56
+ * leaves alone (archon#271). Refuses when an adopted record cannot be read.
57
+ */
58
+ planDestroy(id: OrgId): Promise<{
59
+ steps: string[];
60
+ kept: string[];
61
+ }>;
41
62
  }
@@ -1,4 +1,5 @@
1
- import type { PlatformLiveReaders, PlatformStepExecutor } from '../plan/index.js';
1
+ import type { Plan } from '../plan.js';
2
+ import type { PlatformLiveReaders, PlatformStepExecutor, StepSystem } from '../plan/index.js';
2
3
  import type { FoundryPlatformStepExecutorOptions } from '../plan/platform-providers.js';
3
4
  import type { FoundryStoreDb } from '../store/postgres.js';
4
5
  import type { BootstrapCompany, BootstrapOwner } from './declare.js';
@@ -29,6 +30,8 @@ export interface RootCredentials {
29
30
  /** What Archon's onboarding screen shows: one event per phase or round. */
30
31
  export interface BootstrapProgress {
31
32
  phase: 'declare' | 'plan' | 'apply' | 'done';
33
+ /** The system the event touches: the steps' own when a round's steps share one, `postgres` for a declare, `foundry` otherwise. */
34
+ system: StepSystem;
32
35
  round?: number;
33
36
  message: string;
34
37
  steps?: string[];
@@ -100,6 +103,8 @@ export declare class Redactor {
100
103
  addDeep(value: unknown, depth?: number, seen?: Set<unknown>): void;
101
104
  text(input: string): string;
102
105
  }
106
+ /** The one system every step of `plan` touches, or `foundry` when they differ or there are none. */
107
+ export declare function roundSystem(plan: Plan): StepSystem;
103
108
  /** Wraps the secret-carrying ports inside one step's deps so what they deliver or resolve is redacted. */
104
109
  export declare function guardStepDeps(executor: BootstrapExecutorDeps | undefined, redactor: Redactor, deliverSecret: (ref: string, secret: string) => Promise<void>, heldKeys: BootstrapPlatformDeps['heldKeys']): BootstrapExecutorDeps;
105
110
  /**
@@ -61,6 +61,12 @@ const unusedOrgExecutor = {
61
61
  throw new Error('bootstrapPlatform never destroys an org');
62
62
  },
63
63
  };
64
+ /** The one system every step of `plan` touches, or `foundry` when they differ or there are none. */
65
+ export function roundSystem(plan) {
66
+ const systems = new Set(plan.steps.map((s) => s.system));
67
+ const [only] = systems;
68
+ return systems.size === 1 && only ? only : 'foundry';
69
+ }
64
70
  const unusedOrgReader = { listResources: async () => [] };
65
71
  /** The audit log with every free-text field run through the redactor before it is stored. */
66
72
  function redactingAudit(inner, redactor) {
@@ -76,6 +82,10 @@ function redactingAudit(inner, redactor) {
76
82
  plan: plan(entry.plan),
77
83
  ...(entry.reason === undefined ? {} : { reason: redactor.text(entry.reason) }),
78
84
  }),
85
+ recordAdoption: (entry) => inner.recordAdoption({
86
+ ...entry,
87
+ ...(entry.reason === undefined ? {} : { reason: redactor.text(entry.reason) }),
88
+ }),
79
89
  recordDestroy: (entry) => inner.recordDestroy({
80
90
  ...entry,
81
91
  ...(entry.reason === undefined ? {} : { reason: redactor.text(entry.reason) }),
@@ -189,20 +199,29 @@ export async function bootstrapPlatform(input, deps) {
189
199
  const orgStore = createPostgresDeclaredOrgStore(deps.db);
190
200
  const platformStore = createPostgresPlatformStore(deps.db);
191
201
  if (await orgStore.get(COMPANY_INCLUDED_ORG_ID)) {
192
- emit({ phase: 'declare', message: 'declared org already written' });
202
+ emit({ phase: 'declare', system: 'postgres', message: 'declared org already written' });
193
203
  }
194
204
  else {
195
205
  await orgStore.create(declaredCompanyOrg(input.company, input.owner));
196
- emit({ phase: 'declare', message: `declared org "${input.company.name}" written` });
206
+ emit({
207
+ phase: 'declare',
208
+ system: 'postgres',
209
+ message: `declared org "${input.company.name}" written`,
210
+ });
197
211
  }
198
212
  const existing = await platformStore.get();
199
213
  if (existing) {
200
- emit({ phase: 'declare', message: `declared platform already written for ${existing.apex}` });
214
+ emit({
215
+ phase: 'declare',
216
+ system: 'postgres',
217
+ message: `declared platform already written for ${existing.apex}`,
218
+ });
201
219
  // Only serverIpv4, through the validating `put`; no other field is touched.
202
220
  if (input.serverIpv4 && input.serverIpv4 !== existing.serverIpv4) {
203
221
  await platformStore.put({ ...existing, serverIpv4: input.serverIpv4 });
204
222
  emit({
205
223
  phase: 'declare',
224
+ system: 'postgres',
206
225
  message: `declared platform serverIpv4 set to ${input.serverIpv4}`,
207
226
  });
208
227
  }
@@ -211,6 +230,7 @@ export async function bootstrapPlatform(input, deps) {
211
230
  await platformStore.put(declaredPlatformFor(input.company.apex, input.serverIpv4));
212
231
  emit({
213
232
  phase: 'declare',
233
+ system: 'postgres',
214
234
  message: `declared platform written for ${input.company.apex}`,
215
235
  });
216
236
  }
@@ -278,12 +298,14 @@ export async function bootstrapPlatform(input, deps) {
278
298
  maxRounds: deps.maxRounds,
279
299
  onPlan: (round, plan) => emit({
280
300
  phase: 'plan',
301
+ system: roundSystem(plan),
281
302
  round,
282
303
  message: `round ${round}: ${plan.steps.length} step(s) planned`,
283
304
  steps: plan.steps.map((s) => s.description),
284
305
  }),
285
306
  onApplied: (round, plan) => emit({
286
307
  phase: 'apply',
308
+ system: roundSystem(plan),
287
309
  round,
288
310
  message: `round ${round}: applied`,
289
311
  steps: plan.steps.map((s) => s.description),
@@ -310,6 +332,7 @@ export async function bootstrapPlatform(input, deps) {
310
332
  };
311
333
  emit({
312
334
  phase: 'done',
335
+ system: 'foundry',
313
336
  message: driven.failed
314
337
  ? `stopped: ${result.failed?.message}`
315
338
  : `${result.applied.length} step(s) applied, ${blocked.length} blocked`,
@@ -1,6 +1,16 @@
1
1
  import type { DeclaredOrg, OrgId, OwnerIdentity } from '../org.js';
2
2
  import type { DeclaredPlatform } from '../platform.js';
3
- /** Company Included's own org id, the same literal `test/fakes.ts`'s `basePlatform()` uses for `ownerOrgId`. */
3
+ /**
4
+ * Company Included's own org id, the same literal `test/fakes.ts`'s
5
+ * `basePlatform()` uses for `ownerOrgId`. Must be a uuid: Archon's company
6
+ * pages resolve a declared org only by uuid (archon issue #40,
7
+ * `src/lib/companies/index.ts`'s `getCompany`/`isUuid`), so a non-uuid id
8
+ * 404s every one of them, Company Included's included (foundry#435).
9
+ * `migrations/0040_company_included_uuid.sql` rewrites the live row and
10
+ * every column that references it from the old literal
11
+ * `org_company_included` to this value; this constant and that migration's
12
+ * literal must agree.
13
+ */
4
14
  export declare const COMPANY_INCLUDED_ORG_ID: OrgId;
5
15
  export declare const DEFAULT_GIT_REF = "main";
6
16
  /**
@@ -1,8 +1,18 @@
1
1
  import { validateOwners } from '../org.js';
2
2
  import { PLATFORM_SERVICE_KEYS, defaultPlatformHostname } from '../platform.js';
3
3
  import { VERSIONS } from './versions.js';
4
- /** Company Included's own org id, the same literal `test/fakes.ts`'s `basePlatform()` uses for `ownerOrgId`. */
5
- export const COMPANY_INCLUDED_ORG_ID = 'org_company_included';
4
+ /**
5
+ * Company Included's own org id, the same literal `test/fakes.ts`'s
6
+ * `basePlatform()` uses for `ownerOrgId`. Must be a uuid: Archon's company
7
+ * pages resolve a declared org only by uuid (archon issue #40,
8
+ * `src/lib/companies/index.ts`'s `getCompany`/`isUuid`), so a non-uuid id
9
+ * 404s every one of them, Company Included's included (foundry#435).
10
+ * `migrations/0040_company_included_uuid.sql` rewrites the live row and
11
+ * every column that references it from the old literal
12
+ * `org_company_included` to this value; this constant and that migration's
13
+ * literal must agree.
14
+ */
15
+ export const COMPANY_INCLUDED_ORG_ID = 'e4d20889-a63d-42b6-b670-2bdd4472449b';
6
16
  export const DEFAULT_GIT_REF = 'main';
7
17
  /**
8
18
  * The Stalwart image tag the platform's mail step runs. `platform.mail.gitRef`
package/dist/index.d.ts CHANGED
@@ -2,19 +2,24 @@ export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig
2
2
  export { APP_ENTRY_KEY, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, appEntry, ownerMailboxes, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateSite, } from './org.js';
3
3
  export type { DeclaredPlatform, PlatformService, PlatformServiceKey } from './platform.js';
4
4
  export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
5
+ import { type PinnedVersions } from './bootstrap/versions.js';
6
+ export type { PinnedVersions };
7
+ export declare const PINNED_VERSIONS: Readonly<PinnedVersions>;
5
8
  export type { RequestableField, CallsChange, StorageLimitChange, AiLimitChange, DomainChange, MailTierChange, RequestedChangeValue, ChangeClassification, RequestedChangeState, RequestedChange, } from './requested-changes.js';
6
9
  export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
7
10
  export type { SlugRejectionReason, SlugRejection } from './slug.js';
8
11
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
9
- export type { Checkpoint, LastApplied, AppliedState } from './state.js';
12
+ export type { Checkpoint, LastApplied, AppliedState, DestroyRecord, DestroyStepRecord, } from './state.js';
10
13
  export type { PlanStep, UnmanagedItem, Plan } from './plan.js';
11
- export type { AppliedVersion, AppliedVersionsStore, DeclaredOrgStore, PlatformStore, RequestedChangeStore, StaleUpdate, UpdateOptions, } from './store.js';
12
- export type { PlanApply } from './apply.js';
14
+ export type { AppliedVersion, AppliedVersionsStore, DeclaredOrgStore, HeldLease, MutationLease, PlatformStore, RequestedChangeStore, StaleUpdate, UpdateOptions, } from './store.js';
15
+ export { StaleLeaseError } from './store.js';
16
+ export type { PlanApply, DestroyProgress } from './apply.js';
13
17
  export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
14
18
  export { createPostgresAuditLog } from './store/audit.js';
15
19
  export type { FoundryStoreDb } from './store/postgres.js';
16
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';
17
- export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, PlanDetail, PlatformPlanDetail, 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, PlatformPlanDetail, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
22
+ export { AdoptRefusedError } from './plan/index.js';
18
23
  export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, 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, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
19
24
  export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
20
25
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
package/dist/index.js CHANGED
@@ -1,10 +1,17 @@
1
1
  export { APP_ENTRY_KEY, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, appEntry, ownerMailboxes, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateSite, } from './org.js';
2
2
  export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
3
+ // archon#273: the pinned platform service versions the planner deploys, so a
4
+ // host can show declared against running. Readonly: it is the planner's own
5
+ // object, not a copy.
6
+ import { VERSIONS } from './bootstrap/versions.js';
7
+ export const PINNED_VERSIONS = VERSIONS;
3
8
  export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
4
9
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
10
+ export { StaleLeaseError } from './store.js';
5
11
  export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
6
12
  export { createPostgresAuditLog } from './store/audit.js';
7
13
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
14
+ export { AdoptRefusedError } from './plan/index.js';
8
15
  export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, 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, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
9
16
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
10
17
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
@@ -0,0 +1,8 @@
1
+ -- @wtfalch/foundry: #259 -- per-org calls Matrix version pin, holding an
2
+ -- org's Synapse/MAS release during an ordered rollout. Null holds no pin:
3
+ -- the stack follows versions.json, same as every org today. Mirrors
4
+ -- src/store/schema.ts's `declaredOrgs` table additions.
5
+
6
+ alter table foundry_declared_orgs
7
+ add column if not exists calls_version_pin_synapse text,
8
+ add column if not exists calls_version_pin_mas text;
@@ -0,0 +1,7 @@
1
+ -- @wtfalch/foundry: which steps of a destroy have finished, and which failed.
2
+ -- Sits beside `0002_applied_state.sql`'s `checkpoints` (an apply's finished
3
+ -- steps) and is read the same way, from the org's applied state. `DestroyRecord`
4
+ -- in `state.ts`. Null until a destroy has started; a destroy that finishes
5
+ -- deletes the org and this row with it.
6
+
7
+ alter table foundry_applied_state add column if not exists destroy jsonb;
@@ -0,0 +1,72 @@
1
+ -- @wtfalch/foundry: Company Included's declared org id was minted by hand as
2
+ -- the literal 'org_company_included' rather than a uuid (bootstrap/declare.ts).
3
+ -- Archon resolves a declared org only by uuid (archon issue #40, `isUuid` in
4
+ -- `src/lib/companies/index.ts`'s `getCompany`), so every company page for our
5
+ -- own company 404s (foundry#435). This rewrites the live row, and every
6
+ -- column across this package's own tables that carries that same literal, to
7
+ -- the uuid `declare.ts`'s `COMPANY_INCLUDED_ORG_ID` now exports. The two
8
+ -- literals below must agree with that constant.
9
+ --
10
+ -- Idempotent: every statement is a no-op once the row has already moved,
11
+ -- because each WHERE clause only ever matches the old literal. Safe for a
12
+ -- still-running old release during the rolling deploy: nothing outside this
13
+ -- package's own one-time `bootstrapPlatform` idempotency check
14
+ -- (`bootstrap.ts`'s `orgStore.get(COMPANY_INCLUDED_ORG_ID)`) reads this id as
15
+ -- a hardcoded literal rather than from the declared platform row it is
16
+ -- already running against (`plan/platform-providers.ts`'s
17
+ -- `context.platform.ownerOrgId`), and Company Included's platform is already
18
+ -- bootstrapped -- that check has nothing left to do either way.
19
+ --
20
+ -- `foundry_applied_state` and `foundry_requested_changes` both carry a
21
+ -- foreign key to `foundry_declared_orgs (id)` with no `ON UPDATE CASCADE`,
22
+ -- so the primary key cannot move while they still point at the old value.
23
+ -- Each is dropped and re-added around the rewrite rather than made
24
+ -- permanently cascading, which would let a future bug silently rename an
25
+ -- org's rows out from under its history instead of failing loudly.
26
+ -- (`foundry_adoptions` is gone -- 0032 dropped it -- so it needs no such
27
+ -- treatment here.)
28
+
29
+ alter table foundry_applied_state drop constraint if exists foundry_applied_state_org_fkey;
30
+ alter table foundry_requested_changes drop constraint if exists foundry_requested_changes_org_fkey;
31
+
32
+ update foundry_declared_orgs
33
+ set id = 'e4d20889-a63d-42b6-b670-2bdd4472449b'
34
+ where id = 'org_company_included';
35
+
36
+ update foundry_applied_state
37
+ set org_id = 'e4d20889-a63d-42b6-b670-2bdd4472449b'
38
+ where org_id = 'org_company_included';
39
+
40
+ update foundry_requested_changes
41
+ set org_id = 'e4d20889-a63d-42b6-b670-2bdd4472449b'
42
+ where org_id = 'org_company_included';
43
+
44
+ update foundry_runs
45
+ set org_id = 'e4d20889-a63d-42b6-b670-2bdd4472449b'
46
+ where org_id = 'org_company_included';
47
+
48
+ update foundry_audit_events
49
+ set org_id = 'e4d20889-a63d-42b6-b670-2bdd4472449b'
50
+ where org_id = 'org_company_included';
51
+
52
+ update foundry_declared_platform
53
+ set owner_org_id = 'e4d20889-a63d-42b6-b670-2bdd4472449b'
54
+ where owner_org_id = 'org_company_included';
55
+
56
+ update foundry_applied_versions
57
+ set service = 'e4d20889-a63d-42b6-b670-2bdd4472449b'
58
+ where service = 'org_company_included';
59
+
60
+ do $$
61
+ begin
62
+ if not exists (select 1 from pg_constraint where conname = 'foundry_applied_state_org_fkey') then
63
+ alter table foundry_applied_state
64
+ add constraint foundry_applied_state_org_fkey foreign key (org_id)
65
+ references foundry_declared_orgs (id);
66
+ end if;
67
+ if not exists (select 1 from pg_constraint where conname = 'foundry_requested_changes_org_fkey') then
68
+ alter table foundry_requested_changes
69
+ add constraint foundry_requested_changes_org_fkey foreign key (org_id)
70
+ references foundry_declared_orgs (id);
71
+ end if;
72
+ end $$;
@@ -0,0 +1,132 @@
1
+ -- @wtfalch/foundry: migration 0040 moved Company Included's declared org id
2
+ -- from the literal 'org_company_included' to the uuid
3
+ -- 'e4d20889-a63d-42b6-b670-2bdd4472449b' in every org_id (and org_id-shaped)
4
+ -- column, but never looked inside a jsonb value. A hosting step's key is
5
+ -- `<orgId>:<entryKey>` (platform-diff.ts's `appStepKey`), recorded as
6
+ -- `foundry_applied_state.service_ids`'s own keys
7
+ -- (`coolify:app:<orgId>:<entryKey>`, `cloudflare:access:<orgId>:<entryKey>`)
8
+ -- and as `foundry_runs.step_ids` array entries of the identical shape. After
9
+ -- 0040, the planner looks for `coolify:app:e4d20889-...:<entry>`, finds
10
+ -- nothing under the still-literal key, and can plan to create Company
11
+ -- Included's Coolify apps a second time (#452).
12
+ --
13
+ -- `foundry_runs.checkpoints` carries the same step strings
14
+ -- (`runner/runner.ts`'s `Checkpoint.step`) and is compared by exact string
15
+ -- against `step_ids` to decide what a resumed run has already done
16
+ -- (`done.has(stepId)`); rewriting `step_ids` alone would make an
17
+ -- already-completed step look undone and run it again. `failure.step` names
18
+ -- which step actually failed, same shape. `foundry_applied_state.checkpoints`
19
+ -- and `.destroy` (`state.ts`'s `Checkpoint`/`DestroyRecord`) carry the same
20
+ -- step strings too, as history rather than anything matched against, but are
21
+ -- rewritten the same way so nothing here still reads as Company Included's
22
+ -- old id.
23
+ --
24
+ -- Every WHERE clause below matches only a key, array element or `step` field
25
+ -- that still contains the old literal, so a second run is a no-op.
26
+
27
+ update foundry_applied_state
28
+ set service_ids = (
29
+ select coalesce(
30
+ jsonb_object_agg(
31
+ replace(key, 'org_company_included', 'e4d20889-a63d-42b6-b670-2bdd4472449b'),
32
+ value
33
+ ),
34
+ '{}'::jsonb
35
+ )
36
+ from jsonb_each(service_ids)
37
+ )
38
+ where exists (
39
+ select 1 from jsonb_object_keys(service_ids) k
40
+ where k like '%org_company_included%'
41
+ );
42
+
43
+ update foundry_applied_state
44
+ set checkpoints = (
45
+ select coalesce(
46
+ jsonb_agg(
47
+ case
48
+ when (c ->> 'step') like '%org_company_included%'
49
+ then jsonb_set(c, '{step}',
50
+ to_jsonb(replace(c ->> 'step', 'org_company_included',
51
+ 'e4d20889-a63d-42b6-b670-2bdd4472449b')))
52
+ else c
53
+ end
54
+ ),
55
+ '[]'::jsonb
56
+ )
57
+ from jsonb_array_elements(checkpoints) as c
58
+ )
59
+ where exists (
60
+ select 1 from jsonb_array_elements(checkpoints) c
61
+ where (c ->> 'step') like '%org_company_included%'
62
+ );
63
+
64
+ update foundry_applied_state
65
+ set destroy = jsonb_set(
66
+ destroy,
67
+ '{steps}',
68
+ (select coalesce(
69
+ jsonb_agg(
70
+ case
71
+ when (s ->> 'step') like '%org_company_included%'
72
+ then jsonb_set(s, '{step}',
73
+ to_jsonb(replace(s ->> 'step', 'org_company_included',
74
+ 'e4d20889-a63d-42b6-b670-2bdd4472449b')))
75
+ else s
76
+ end
77
+ ),
78
+ '[]'::jsonb
79
+ )
80
+ from jsonb_array_elements(destroy -> 'steps') as s)
81
+ )
82
+ where destroy is not null
83
+ and exists (
84
+ select 1 from jsonb_array_elements(destroy -> 'steps') s
85
+ where (s ->> 'step') like '%org_company_included%'
86
+ );
87
+
88
+ update foundry_runs
89
+ set step_ids = (
90
+ select coalesce(
91
+ jsonb_agg(
92
+ replace(elem, 'org_company_included', 'e4d20889-a63d-42b6-b670-2bdd4472449b')
93
+ ),
94
+ '[]'::jsonb
95
+ )
96
+ from jsonb_array_elements_text(step_ids) as elem
97
+ )
98
+ where exists (
99
+ select 1 from jsonb_array_elements_text(step_ids) e
100
+ where e like '%org_company_included%'
101
+ );
102
+
103
+ update foundry_runs
104
+ set checkpoints = (
105
+ select coalesce(
106
+ jsonb_agg(
107
+ case
108
+ when (c ->> 'step') like '%org_company_included%'
109
+ then jsonb_set(c, '{step}',
110
+ to_jsonb(replace(c ->> 'step', 'org_company_included',
111
+ 'e4d20889-a63d-42b6-b670-2bdd4472449b')))
112
+ else c
113
+ end
114
+ ),
115
+ '[]'::jsonb
116
+ )
117
+ from jsonb_array_elements(checkpoints) as c
118
+ )
119
+ where exists (
120
+ select 1 from jsonb_array_elements(checkpoints) c
121
+ where (c ->> 'step') like '%org_company_included%'
122
+ );
123
+
124
+ update foundry_runs
125
+ set failure = jsonb_set(
126
+ failure,
127
+ '{step}',
128
+ to_jsonb(replace(failure ->> 'step', 'org_company_included',
129
+ 'e4d20889-a63d-42b6-b670-2bdd4472449b'))
130
+ )
131
+ where failure is not null
132
+ and (failure ->> 'step') like '%org_company_included%';
@@ -0,0 +1,12 @@
1
+ -- @wtfalch/foundry: the lock behind `FoundryPlanApply`'s apply, destroy, adopt
2
+ -- and release (#441). One row per scope: `company:<orgId>` or `platform`.
3
+ -- `token` is the fencing token; a row is never deleted, so it only rises, and
4
+ -- a stale holder's `putAppliedState` / `putServiceIds` is refused by
5
+ -- comparing against it. A released lease has `expires_at` set to now.
6
+
7
+ create table if not exists foundry_mutation_leases (
8
+ scope text primary key,
9
+ token bigint not null,
10
+ holder text not null,
11
+ expires_at timestamptz not null
12
+ );
package/dist/org.d.ts CHANGED
@@ -129,6 +129,27 @@ export interface ChatConfig {
129
129
  export interface CallsConfig {
130
130
  enabled: boolean;
131
131
  usesDomain: boolean;
132
+ /**
133
+ * #259: holds this org's calls Matrix stack on an older Synapse/MAS
134
+ * release during an ordered rollout, instead of whatever
135
+ * `versions.json` currently pins. `null`/absent follows the pinned
136
+ * release, same as today.
137
+ *
138
+ * Declared and validated here; not yet read anywhere a tag is actually
139
+ * applied. `executor.ts`'s `coolifyCalls` only ever sets Synapse's/MAS's
140
+ * image tag at creation (its own comment: "#251: only on create --
141
+ * nothing here patches `docker_registry_image_tag` on an app
142
+ * `findOrCreateCallsApp` found already running") -- there is no patch-
143
+ * and-redeploy path for an existing org's stack yet, pinned or not. A
144
+ * rollout mechanism needs that path before this field can hold anything
145
+ * back; building it is outside this change (#259's own "Human steps"
146
+ * raises a related open question about Coolify's auto-deploy that bears
147
+ * on the same path).
148
+ */
149
+ versionPin?: {
150
+ synapse?: string;
151
+ mas?: string;
152
+ } | null;
132
153
  }
133
154
  /**
134
155
  * The issuer's per-service opt-in into the org's `domain` fact.
@@ -157,6 +178,17 @@ export interface IssuerConfig {
157
178
  export interface SiteConfig {
158
179
  enabled: boolean;
159
180
  usesDomain: boolean;
181
+ /**
182
+ * The org's own skin repo for `plan/executor.ts`'s `cmsSite` site app
183
+ * (#245) -- `null` (the default) builds the shared `@wtfalch/cms` repo's
184
+ * `apps/site`, same sample skin as before this field existed. Only the
185
+ * repo the site app builds from; the cms admin app behind it always
186
+ * builds from the shared repo. Where the skin finally lives (an
187
+ * org-owned repo vs. a cms theme document served at runtime) is still
188
+ * undecided in https://github.com/wtfalch/cms/issues/55 -- this field and
189
+ * its default are the only part built until that closes.
190
+ */
191
+ repo?: string | null;
160
192
  }
161
193
  /**
162
194
  * A ZITADEL OIDC application kind foundry can provision inside an org's
@@ -428,8 +460,19 @@ export declare function validateDomain(domain: string): string | null;
428
460
  * - `site.enabled` refuses an `issuerApps` entry named `cms`: the fixed cms
429
461
  * client (#139, `plan/diff.ts`'s `planCmsClient`) already owns the
430
462
  * `zitadel:app:cms` key, and the two would overwrite each other.
463
+ * - `site.repo`, if set, must be a repo under `wtfalch` (#245), the same
464
+ * `REPO` rule {@link validateHostingEntry} already holds `HostingEntry.repo`
465
+ * to.
431
466
  */
432
467
  export declare function validateSite(org: DeclaredOrg): string | null;
468
+ /**
469
+ * #259: `calls.versionPin` holds this org's calls Matrix stack on an older
470
+ * release during an ordered rollout. A pin is a tag, held to the same
471
+ * "no floating tag" rule the pinned releases in `versions.json` already
472
+ * follow ({@link floatingTagBlocked}) -- a pin that floats would defeat the
473
+ * reason to pin at all.
474
+ */
475
+ export declare function validateCalls(org: DeclaredOrg): string | null;
433
476
  /**
434
477
  * Per-org SSO. Decided to exist in #14 ("Also in scope") and its resolved
435
478
  * research #3 ("per-org SSO code exists unused"); nothing about its
@@ -613,9 +656,20 @@ export declare function ownerMailboxRecorded(box: OwnerMailbox, recordedIds: Rea
613
656
  * two rules `validateHosting` already applies to `issuerApps`' names, for
614
657
  * the same reason: a duplicate or blank entry can never mean anything
615
658
  * different from leaving it out.
659
+ *
660
+ * A domain plus `mail.mode === 'received'` is tier 2 (own domain, MX stays
661
+ * at Google or Microsoft), which William dropped on 2026-10-04 (#422):
662
+ * only tier 1 (no domain) and tier 3 (own domain, `hosted`) are offered.
663
+ * Refused unless `opts.allowExistingTier2` says this exact combination was
664
+ * already the stored state before this write -- `store/postgres.ts`'s
665
+ * `update` passes that for an org already grandfathered into tier 2, so a
666
+ * patch that does not touch `domain` or `mail.mode` still goes through.
667
+ * `create` never passes it: a brand new org cannot already be grandfathered.
616
668
  */
617
669
  export declare function validateMail(org: Pick<DeclaredOrg, 'mail' | 'domain'> & {
618
670
  owners?: readonly OwnerIdentity[];
671
+ }, opts?: {
672
+ allowExistingTier2?: boolean;
619
673
  }): string | null;
620
674
  /**
621
675
  * The form an app foundry does not own takes. Decided in #7's `#15`
package/dist/org.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { floatingTagBlocked } from './bootstrap/versions.js';
1
2
  /** {@link MailConfig.mailApps} when the org declares none of its own (#34, decided 2026-09-30). */
2
3
  export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
3
4
  /** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
@@ -483,6 +484,9 @@ export function validateDomain(domain) {
483
484
  * - `site.enabled` refuses an `issuerApps` entry named `cms`: the fixed cms
484
485
  * client (#139, `plan/diff.ts`'s `planCmsClient`) already owns the
485
486
  * `zitadel:app:cms` key, and the two would overwrite each other.
487
+ * - `site.repo`, if set, must be a repo under `wtfalch` (#245), the same
488
+ * `REPO` rule {@link validateHostingEntry} already holds `HostingEntry.repo`
489
+ * to.
486
490
  */
487
491
  export function validateSite(org) {
488
492
  if (org.site.enabled && !org.domain) {
@@ -497,6 +501,32 @@ export function validateSite(org) {
497
501
  if (org.site.enabled && org.hosting.issuerApps.some((app) => app.name === 'cms')) {
498
502
  return "site.enabled reserves the issuerApps name 'cms'";
499
503
  }
504
+ if (org.site.repo != null && !REPO.test(org.site.repo)) {
505
+ return 'site.repo must be a repo under wtfalch, or null';
506
+ }
507
+ return null;
508
+ }
509
+ /**
510
+ * #259: `calls.versionPin` holds this org's calls Matrix stack on an older
511
+ * release during an ordered rollout. A pin is a tag, held to the same
512
+ * "no floating tag" rule the pinned releases in `versions.json` already
513
+ * follow ({@link floatingTagBlocked}) -- a pin that floats would defeat the
514
+ * reason to pin at all.
515
+ */
516
+ export function validateCalls(org) {
517
+ const pin = org.calls.versionPin;
518
+ if (!pin)
519
+ return null;
520
+ if (pin.synapse !== undefined) {
521
+ const blocked = floatingTagBlocked('Synapse', pin.synapse, 'v1.161.0');
522
+ if (blocked)
523
+ return `calls.versionPin ${blocked}`;
524
+ }
525
+ if (pin.mas !== undefined) {
526
+ const blocked = floatingTagBlocked('matrix-authentication-service', pin.mas, '1.24.0');
527
+ if (blocked)
528
+ return `calls.versionPin ${blocked}`;
529
+ }
500
530
  return null;
501
531
  }
502
532
  const ORG_NUMBER_PATTERN = /^\d{9}$/;
@@ -655,9 +685,21 @@ function validateOwnerMailboxes(org) {
655
685
  * two rules `validateHosting` already applies to `issuerApps`' names, for
656
686
  * the same reason: a duplicate or blank entry can never mean anything
657
687
  * different from leaving it out.
688
+ *
689
+ * A domain plus `mail.mode === 'received'` is tier 2 (own domain, MX stays
690
+ * at Google or Microsoft), which William dropped on 2026-10-04 (#422):
691
+ * only tier 1 (no domain) and tier 3 (own domain, `hosted`) are offered.
692
+ * Refused unless `opts.allowExistingTier2` says this exact combination was
693
+ * already the stored state before this write -- `store/postgres.ts`'s
694
+ * `update` passes that for an org already grandfathered into tier 2, so a
695
+ * patch that does not touch `domain` or `mail.mode` still goes through.
696
+ * `create` never passes it: a brand new org cannot already be grandfathered.
658
697
  */
659
- export function validateMail(org) {
698
+ export function validateMail(org, opts) {
660
699
  const { mail } = org;
700
+ if (org.domain && mail.mode === 'received' && !opts?.allowExistingTier2) {
701
+ return `mail.mode 'received' (tier 2, own domain with mail elsewhere) is not offered; use 'hosted' or leave the domain off`;
702
+ }
661
703
  const address = mail.ownerMailboxAddress ?? null;
662
704
  if (address !== null) {
663
705
  if (!OWNER_EMAIL_PATTERN.test(address)) {