@wtfalch/foundry 0.16.16 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) 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 +18 -6
  6. package/dist/index.d.ts +11 -6
  7. package/dist/index.js +8 -1
  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/migrations/0043_platform_names.sql +25 -0
  14. package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
  15. package/dist/org.d.ts +128 -3
  16. package/dist/org.js +186 -1
  17. package/dist/plan/adopt.d.ts +76 -0
  18. package/dist/plan/adopt.js +184 -0
  19. package/dist/plan/agora.d.ts +21 -0
  20. package/dist/plan/agora.js +37 -0
  21. package/dist/plan/broker-entries.d.ts +9 -1
  22. package/dist/plan/broker-entries.js +13 -5
  23. package/dist/plan/chat-matrix.d.ts +1 -2
  24. package/dist/plan/chat-matrix.js +15 -4
  25. package/dist/plan/core-files.d.ts +25 -0
  26. package/dist/plan/core-files.js +25 -0
  27. package/dist/plan/diff.d.ts +27 -3
  28. package/dist/plan/diff.js +264 -86
  29. package/dist/plan/edge-worker.d.ts +114 -0
  30. package/dist/plan/edge-worker.js +201 -0
  31. package/dist/plan/entry-ai-key.d.ts +3 -3
  32. package/dist/plan/entry-files-key.d.ts +3 -3
  33. package/dist/plan/entry-names-key.d.ts +73 -0
  34. package/dist/plan/entry-names-key.js +236 -0
  35. package/dist/plan/executor.d.ts +292 -24
  36. package/dist/plan/executor.js +745 -77
  37. package/dist/plan/index.d.ts +6 -2
  38. package/dist/plan/index.js +3 -1
  39. package/dist/plan/live.d.ts +9 -2
  40. package/dist/plan/login-host.d.ts +13 -2
  41. package/dist/plan/login-host.js +26 -7
  42. package/dist/plan/plan-apply.d.ts +153 -6
  43. package/dist/plan/plan-apply.js +639 -78
  44. package/dist/plan/platform-diff.d.ts +106 -1
  45. package/dist/plan/platform-diff.js +182 -2
  46. package/dist/plan/platform-org.d.ts +45 -1
  47. package/dist/plan/platform-org.js +34 -3
  48. package/dist/plan/platform-providers.d.ts +45 -1
  49. package/dist/plan/platform-providers.js +585 -16
  50. package/dist/plan/providers-readers.d.ts +2 -0
  51. package/dist/plan/providers-readers.js +4 -0
  52. package/dist/plan/rollout.d.ts +52 -0
  53. package/dist/plan/rollout.js +37 -0
  54. package/dist/plan/step-system.d.ts +22 -0
  55. package/dist/plan/step-system.js +38 -0
  56. package/dist/plan.d.ts +3 -0
  57. package/dist/platform.d.ts +22 -3
  58. package/dist/platform.js +6 -0
  59. package/dist/state.d.ts +13 -0
  60. package/dist/store/audit.js +15 -1
  61. package/dist/store/postgres.d.ts +1 -1
  62. package/dist/store/postgres.js +142 -15
  63. package/dist/store/schema.d.ts +202 -5
  64. package/dist/store/schema.js +18 -1
  65. package/dist/store.d.ts +47 -4
  66. package/dist/store.js +9 -1
  67. package/package.json +1 -1
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
@@ -299,6 +331,62 @@ export interface HostingEntry {
299
331
  env: Record<string, EnvValue>;
300
332
  /** What the entry signs in through. Absent means it uses nothing. */
301
333
  uses?: HostingEntryUses;
334
+ /**
335
+ * F8: a Cloudflare Worker foundry deploys from this entry's own repo,
336
+ * beside the app, on the org's own zone. Only our own repos may use this
337
+ * (#318: outside customers need Workers for Platforms, not built yet), so
338
+ * `validateEntryEdge` refuses it unless `repo` is set (every non-null
339
+ * `repo` already matches `wtfalch/...`, {@link REPO}) and the route's zone
340
+ * is the org's own domain.
341
+ */
342
+ edge?: EdgeConfig;
343
+ }
344
+ /**
345
+ * One Cloudflare rate limiting binding on a Worker (#318) -- Cloudflare's own
346
+ * "simple" shape: a fixed number of requests per period, where `period` is
347
+ * the only two values Cloudflare's rate limiting binding accepts.
348
+ */
349
+ export interface EdgeRateLimitBinding {
350
+ name: string;
351
+ namespaceId: string;
352
+ limit: number;
353
+ period: 10 | 60;
354
+ }
355
+ /** {@link HostingEntry.edge}'s bindings, one list per binding kind -- only rate limits today. */
356
+ export interface EdgeBindings {
357
+ rateLimits: EdgeRateLimitBinding[];
358
+ }
359
+ /**
360
+ * A plain Worker deployed beside a hosting entry (#318), named by one route
361
+ * on the org's own zone. `artifactUrl`/`artifactSha256` name a *built*
362
+ * module -- the entry's own repo builds and publishes it (a release asset,
363
+ * typically), foundry only fetches and verifies it against the declared
364
+ * digest before uploading (estate DECISIONS.md, 2026-10-04, "offline.tf
365
+ * Worker setup": "The app's automated build process builds the bundle
366
+ * outside Foundry; Foundry consumes and deploys the artifact through the
367
+ * provider boundary... No manual build step and no source checkout/bundling
368
+ * in Foundry"). `validateEntryEdge` refuses anything that is not an
369
+ * `https://` URL -- a bare path or relative file name is exactly the shape
370
+ * this decision rules out.
371
+ */
372
+ export interface EdgeConfig {
373
+ /** Where foundry fetches the built module from, behind `WorkersPort` -- never checked out or bundled here. */
374
+ artifactUrl: string;
375
+ /** The built module's sha256, lowercase hex. Verified against the fetched bytes before upload, and the sole signal `planEdgeWorkers` diffs on: an org that rebuilds under an unchanged URL redeploys only when this changes. */
376
+ artifactSha256: string;
377
+ /**
378
+ * One route per entry today. A wildcard route (`*.<domain>/*`) matches
379
+ * every subdomain, which also captures every other hostname the org
380
+ * serves under that domain -- its own app included, e.g. `app.<domain>`
381
+ * -- since Cloudflare routes the most specific match but a Worker route
382
+ * has no path-based exclusion of its own; declare it carefully so it
383
+ * does not shadow the app. The apex itself (`<domain>`, no subdomain
384
+ * label) is never matched by a wildcard route -- not even partially --
385
+ * so an org that wants the apex reachable through a Worker declares a
386
+ * separate entry with that exact route; there is no exclusion list.
387
+ */
388
+ route: string;
389
+ bindings?: EdgeBindings;
302
390
  }
303
391
  /**
304
392
  * Sign-in for one entry: one ZITADEL client and one broker entry, signing in
@@ -352,7 +440,17 @@ export interface HostingEntryUses {
352
440
  auth?: EntryAuth;
353
441
  ai?: EntryAi;
354
442
  files?: EntryFiles;
443
+ /**
444
+ * F7 (#317): the entry gets its own names key, minted directly below the
445
+ * org with the names service, and `NAMES_URL`/`NAMES_API_KEY` in its env.
446
+ * Serves only the org's own domain (names claims `<label>.<org's domain>`),
447
+ * so `validateEntryNames` refuses it without `hosting.usesDomain` and an
448
+ * org `domain`.
449
+ */
450
+ names?: true;
355
451
  }
452
+ /** The env names `uses.names` writes; `env` may not name them (#317). */
453
+ export declare const NAMES_ENTRY_ENV_NAMES: readonly ["NAMES_URL", "NAMES_API_KEY"];
356
454
  /**
357
455
  * A plain string is pushed as it is, so `validateHosting` refuses one that
358
456
  * looks like a secret. `{ from }` names a value the plan already mints.
@@ -428,8 +526,19 @@ export declare function validateDomain(domain: string): string | null;
428
526
  * - `site.enabled` refuses an `issuerApps` entry named `cms`: the fixed cms
429
527
  * client (#139, `plan/diff.ts`'s `planCmsClient`) already owns the
430
528
  * `zitadel:app:cms` key, and the two would overwrite each other.
529
+ * - `site.repo`, if set, must be a repo under `wtfalch` (#245), the same
530
+ * `REPO` rule {@link validateHostingEntry} already holds `HostingEntry.repo`
531
+ * to.
431
532
  */
432
533
  export declare function validateSite(org: DeclaredOrg): string | null;
534
+ /**
535
+ * #259: `calls.versionPin` holds this org's calls Matrix stack on an older
536
+ * release during an ordered rollout. A pin is a tag, held to the same
537
+ * "no floating tag" rule the pinned releases in `versions.json` already
538
+ * follow ({@link floatingTagBlocked}) -- a pin that floats would defeat the
539
+ * reason to pin at all.
540
+ */
541
+ export declare function validateCalls(org: DeclaredOrg): string | null;
433
542
  /**
434
543
  * Per-org SSO. Decided to exist in #14 ("Also in scope") and its resolved
435
544
  * research #3 ("per-org SSO code exists unused"); nothing about its
@@ -483,9 +592,14 @@ export interface StorageConfig {
483
592
  * this is the declared value `apply` pushes onto it.
484
593
  *
485
594
  * A bigint-safe decimal string of a non-negative integer, never converted to
486
- * a number. `null` means uncapped. Spend itself (usage, money spent) is
487
- * never declared (#27 "Done when"): it is a runtime fact read live from AI's
488
- * usage ledger, shown beside this limit, never stored here.
595
+ * a number. `null` means no limit is declared -- not uncapped: `apply`
596
+ * (`diff.ts`'s `effectiveAiLimitMicros`) substitutes a default spend cap,
597
+ * `DEFAULT_AI_LIMIT_MICROS`, in that case (#484; Archon creates every
598
+ * company this way, and AI's own budget API has no uncapped
599
+ * representation). A declared numeric limit always wins over the default.
600
+ * Spend itself (usage, money spent) is never declared (#27 "Done when"): it
601
+ * is a runtime fact read live from AI's usage ledger, shown beside this
602
+ * limit, never stored here.
489
603
  */
490
604
  export interface AiConfig {
491
605
  limitMicros: string | null;
@@ -613,9 +727,20 @@ export declare function ownerMailboxRecorded(box: OwnerMailbox, recordedIds: Rea
613
727
  * two rules `validateHosting` already applies to `issuerApps`' names, for
614
728
  * the same reason: a duplicate or blank entry can never mean anything
615
729
  * different from leaving it out.
730
+ *
731
+ * A domain plus `mail.mode === 'received'` is tier 2 (own domain, MX stays
732
+ * at Google or Microsoft), which William dropped on 2026-10-04 (#422):
733
+ * only tier 1 (no domain) and tier 3 (own domain, `hosted`) are offered.
734
+ * Refused unless `opts.allowExistingTier2` says this exact combination was
735
+ * already the stored state before this write -- `store/postgres.ts`'s
736
+ * `update` passes that for an org already grandfathered into tier 2, so a
737
+ * patch that does not touch `domain` or `mail.mode` still goes through.
738
+ * `create` never passes it: a brand new org cannot already be grandfathered.
616
739
  */
617
740
  export declare function validateMail(org: Pick<DeclaredOrg, 'mail' | 'domain'> & {
618
741
  owners?: readonly OwnerIdentity[];
742
+ }, opts?: {
743
+ allowExistingTier2?: boolean;
619
744
  }): string | null;
620
745
  /**
621
746
  * 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). */
@@ -35,6 +36,8 @@ export const FILES_ENTRY_ENV_NAMES = [
35
36
  'FILES_API_KEY',
36
37
  ];
37
38
  export const filesCollectionEnvName = (name) => `FILES_COLLECTION_${name}_ID`;
39
+ /** The env names `uses.names` writes; `env` may not name them (#317). */
40
+ export const NAMES_ENTRY_ENV_NAMES = ['NAMES_URL', 'NAMES_API_KEY'];
38
41
  /** The org's `app` entry: today's one app. */
39
42
  export function appEntry(hosting) {
40
43
  return hosting.entries.find((entry) => entry.key === APP_ENTRY_KEY);
@@ -129,10 +132,30 @@ export function validateHosting(hosting, names) {
129
132
  const invalidFiles = validateEntryFiles(entry, hosting, names?.domain);
130
133
  if (invalidFiles)
131
134
  return invalidFiles;
135
+ const invalidEdge = validateEntryEdge(entry, hosting, names?.domain, names?.locked);
136
+ if (invalidEdge)
137
+ return invalidEdge;
138
+ const invalidNamesUse = validateEntryNames(entry, hosting, names?.domain);
139
+ if (invalidNamesUse)
140
+ return invalidNamesUse;
132
141
  }
133
142
  if (hosting.entries.filter((entry) => entry.uses?.ai?.webhookPath !== undefined).length > 1) {
134
143
  return 'at most one hosting entry may set uses.ai webhookPath: ai keeps one webhook per organisation';
135
144
  }
145
+ // #318 re-review: two entries declaring the same edge route both throw at
146
+ // apply (`cloudflareWorker` refuses a route already bound to a Worker
147
+ // that is not its own), one of which stops Agora and everything planned
148
+ // after it provisioning at all -- refused here instead, before an apply
149
+ // ever reaches it.
150
+ const seenEdgeRoutes = new Set();
151
+ for (const entry of hosting.entries) {
152
+ if (!entry.edge)
153
+ continue;
154
+ if (seenEdgeRoutes.has(entry.edge.route)) {
155
+ return `hosting.entries '${entry.key}' edge route '${entry.edge.route}' is already declared by another entry`;
156
+ }
157
+ seenEdgeRoutes.add(entry.edge.route);
158
+ }
136
159
  const invalidNames = validateEntryHostnames(hosting, names);
137
160
  if (invalidNames)
138
161
  return invalidNames;
@@ -414,6 +437,127 @@ function validateEntryFiles(entry, hosting, domain) {
414
437
  return `${at} writes ${named}; remove it from env`;
415
438
  return null;
416
439
  }
440
+ /** A route pattern's zone apex: the host part before the first `/`, with one leading `*.` label stripped. */
441
+ function edgeRouteZone(route) {
442
+ const slash = route.indexOf('/');
443
+ if (slash < 1)
444
+ return null;
445
+ const host = route.slice(0, slash);
446
+ return host.startsWith('*.') ? host.slice(2) : host;
447
+ }
448
+ const EDGE_ROUTE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+\/.*$/;
449
+ const EDGE_SHA256 = /^[0-9a-f]{64}$/;
450
+ /**
451
+ * Restated, not shared, from `plan/edge-worker.ts`'s `EDGE_WORKER_ORG_HASH_LENGTH`/
452
+ * `EDGE_WORKER_NAME_MAX_LENGTH` -- importing them here would import that
453
+ * module's own `DeclaredOrg`/`HostingEntry` imports right back into this
454
+ * one. `edgeWorkerName`'s shape is `${8-hex-char hash}-${entry.key}-edge`;
455
+ * the hash and the `-edge` suffix are fixed length, so the entry key's own
456
+ * budget against Cloudflare's 63-character Worker name limit is fixed too.
457
+ */
458
+ const EDGE_WORKER_NAME_FIXED_LENGTH = 8 + 1 + '-edge'.length;
459
+ const EDGE_WORKER_NAME_MAX_LENGTH = 63;
460
+ /**
461
+ * `edge`: the entry's own repo (never the shared standard app -- entries
462
+ * with `repo: null` have no Worker source of their own), an `https://`
463
+ * artifact URL with a lowercase-hex sha256 (never a bare path or relative
464
+ * file -- estate DECISIONS.md, 2026-10-04, rules out any checkout or
465
+ * bundling inside foundry), a route whose zone is the org's own domain
466
+ * (#318: our own projects only, never a customer's zone), and a rate limit
467
+ * binding's `period` restricted to the two values Cloudflare's simple rate
468
+ * limiting binding accepts.
469
+ */
470
+ function validateEntryEdge(entry, hosting, domain, locked) {
471
+ const edge = entry.edge;
472
+ if (!edge)
473
+ return null;
474
+ const at = `hosting.entries '${entry.key}' edge`;
475
+ if (!hosting.enabled)
476
+ return `${at} requires hosting.enabled`;
477
+ if (entry.repo === null) {
478
+ return `${at} needs its own repo (repo: null is the shared standard app, not a Worker source)`;
479
+ }
480
+ if (entry.key.length > EDGE_WORKER_NAME_MAX_LENGTH - EDGE_WORKER_NAME_FIXED_LENGTH) {
481
+ 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 ${EDGE_WORKER_NAME_MAX_LENGTH}-character limit`;
482
+ }
483
+ let artifactUrl;
484
+ try {
485
+ artifactUrl = new URL(edge.artifactUrl);
486
+ }
487
+ catch {
488
+ return `${at} artifactUrl must be an absolute URL, not a bare path or relative file`;
489
+ }
490
+ if (artifactUrl.protocol !== 'https:') {
491
+ return `${at} artifactUrl must be an https:// URL`;
492
+ }
493
+ if (artifactUrl.username || artifactUrl.password) {
494
+ return `${at} artifactUrl must not carry userinfo (a signed or credentialed URL ends up stored verbatim in recorded scope ids)`;
495
+ }
496
+ if (artifactUrl.search) {
497
+ return `${at} artifactUrl must not carry a query string (a signed or credentialed URL ends up stored verbatim in recorded scope ids)`;
498
+ }
499
+ // `URL.hash` is '' for a bare trailing `#`, so the raw string is checked.
500
+ if (edge.artifactUrl.includes('#')) {
501
+ return `${at} artifactUrl must not carry a fragment (refused for the same reason as a query string)`;
502
+ }
503
+ if (!EDGE_SHA256.test(edge.artifactSha256)) {
504
+ return `${at} artifactSha256 must be 64 lowercase hex characters`;
505
+ }
506
+ if (!EDGE_ROUTE.test(edge.route)) {
507
+ return `${at} route must be a hostname (an optional leading '*.' label, then at least two more labels) and a path, joined by '/'`;
508
+ }
509
+ const zone = edgeRouteZone(edge.route);
510
+ if (!domain || zone !== domain) {
511
+ return `${at} route '${edge.route}' must be on the org's own domain${domain ? ` (${domain})` : ''}: edge is only for our own projects`;
512
+ }
513
+ // #318 re-review: a Worker route has no path-based exclusion of its own,
514
+ // so a wildcard (`*.<domain>/*`) captures every hostname foundry already
515
+ // owns under the domain -- Agora, Boule, the login host and the rest of
516
+ // `locked-hostnames.ts`'s set -- not only the org's other entries. (A
517
+ // non-wildcard route can only ever be the bare apex here -- the check
518
+ // just above requires `edgeRouteZone(edge.route) === domain`, and that
519
+ // function returns a non-wildcard route's host unchanged -- and the apex
520
+ // alone is never itself a locked name, so there is no direct-match case
521
+ // to refuse alongside this one.)
522
+ if (locked && locked.size > 0 && edge.route.startsWith('*.')) {
523
+ return `${at} route '${edge.route}' is a wildcard: it would also capture ${[...locked].sort().join(', ')}, every hostname foundry already owns under this domain`;
524
+ }
525
+ for (const limit of edge.bindings?.rateLimits ?? []) {
526
+ if (!limit.name.trim())
527
+ return `${at} bindings.rateLimits needs a non-empty name`;
528
+ if (!limit.namespaceId.trim())
529
+ return `${at} bindings.rateLimits needs a non-empty namespaceId`;
530
+ if (!(Number.isInteger(limit.limit) && limit.limit > 0)) {
531
+ return `${at} bindings.rateLimits limit must be a positive whole number`;
532
+ }
533
+ if (limit.period !== 10 && limit.period !== 60) {
534
+ return `${at} bindings.rateLimits period must be 10 or 60 (Cloudflare allows no other value)`;
535
+ }
536
+ }
537
+ return null;
538
+ }
539
+ /**
540
+ * `uses.names`: serves only the org's own domain (names claims
541
+ * `<label>.<domain>`), so it needs `hosting.usesDomain` and a `domain`, and
542
+ * no `env` entry that names a value `uses.names` writes.
543
+ */
544
+ function validateEntryNames(entry, hosting, domain) {
545
+ const names = entry.uses?.names;
546
+ if (names === undefined)
547
+ return null;
548
+ const at = `hosting.entries '${entry.key}' uses.names`;
549
+ if (names !== true)
550
+ return `${at} must be true`;
551
+ if (!hosting.enabled)
552
+ return `${at} requires hosting.enabled`;
553
+ if (!hosting.usesDomain || !domain) {
554
+ return `${at} needs hosting.usesDomain and an org domain: names serves only the org's own domain`;
555
+ }
556
+ const named = Object.keys(entry.env ?? {}).find((name) => NAMES_ENTRY_ENV_NAMES.includes(name));
557
+ if (named)
558
+ return `${at} writes ${named}; remove it from env`;
559
+ return null;
560
+ }
417
561
  function urlHasPassword(value) {
418
562
  try {
419
563
  return new URL(value).password !== '';
@@ -483,6 +627,9 @@ export function validateDomain(domain) {
483
627
  * - `site.enabled` refuses an `issuerApps` entry named `cms`: the fixed cms
484
628
  * client (#139, `plan/diff.ts`'s `planCmsClient`) already owns the
485
629
  * `zitadel:app:cms` key, and the two would overwrite each other.
630
+ * - `site.repo`, if set, must be a repo under `wtfalch` (#245), the same
631
+ * `REPO` rule {@link validateHostingEntry} already holds `HostingEntry.repo`
632
+ * to.
486
633
  */
487
634
  export function validateSite(org) {
488
635
  if (org.site.enabled && !org.domain) {
@@ -497,6 +644,32 @@ export function validateSite(org) {
497
644
  if (org.site.enabled && org.hosting.issuerApps.some((app) => app.name === 'cms')) {
498
645
  return "site.enabled reserves the issuerApps name 'cms'";
499
646
  }
647
+ if (org.site.repo != null && !REPO.test(org.site.repo)) {
648
+ return 'site.repo must be a repo under wtfalch, or null';
649
+ }
650
+ return null;
651
+ }
652
+ /**
653
+ * #259: `calls.versionPin` holds this org's calls Matrix stack on an older
654
+ * release during an ordered rollout. A pin is a tag, held to the same
655
+ * "no floating tag" rule the pinned releases in `versions.json` already
656
+ * follow ({@link floatingTagBlocked}) -- a pin that floats would defeat the
657
+ * reason to pin at all.
658
+ */
659
+ export function validateCalls(org) {
660
+ const pin = org.calls.versionPin;
661
+ if (!pin)
662
+ return null;
663
+ if (pin.synapse !== undefined) {
664
+ const blocked = floatingTagBlocked('Synapse', pin.synapse, 'v1.161.0');
665
+ if (blocked)
666
+ return `calls.versionPin ${blocked}`;
667
+ }
668
+ if (pin.mas !== undefined) {
669
+ const blocked = floatingTagBlocked('matrix-authentication-service', pin.mas, '1.24.0');
670
+ if (blocked)
671
+ return `calls.versionPin ${blocked}`;
672
+ }
500
673
  return null;
501
674
  }
502
675
  const ORG_NUMBER_PATTERN = /^\d{9}$/;
@@ -655,9 +828,21 @@ function validateOwnerMailboxes(org) {
655
828
  * two rules `validateHosting` already applies to `issuerApps`' names, for
656
829
  * the same reason: a duplicate or blank entry can never mean anything
657
830
  * different from leaving it out.
831
+ *
832
+ * A domain plus `mail.mode === 'received'` is tier 2 (own domain, MX stays
833
+ * at Google or Microsoft), which William dropped on 2026-10-04 (#422):
834
+ * only tier 1 (no domain) and tier 3 (own domain, `hosted`) are offered.
835
+ * Refused unless `opts.allowExistingTier2` says this exact combination was
836
+ * already the stored state before this write -- `store/postgres.ts`'s
837
+ * `update` passes that for an org already grandfathered into tier 2, so a
838
+ * patch that does not touch `domain` or `mail.mode` still goes through.
839
+ * `create` never passes it: a brand new org cannot already be grandfathered.
658
840
  */
659
- export function validateMail(org) {
841
+ export function validateMail(org, opts) {
660
842
  const { mail } = org;
843
+ if (org.domain && mail.mode === 'received' && !opts?.allowExistingTier2) {
844
+ return `mail.mode 'received' (tier 2, own domain with mail elsewhere) is not offered; use 'hosted' or leave the domain off`;
845
+ }
661
846
  const address = mail.ownerMailboxAddress ?? null;
662
847
  if (address !== null) {
663
848
  if (!OWNER_EMAIL_PATTERN.test(address)) {
@@ -0,0 +1,76 @@
1
+ import type { DeclaredOrg } from '../org.js';
2
+ import type { Plan } from '../plan.js';
3
+ import type { LiveResource } from './live.js';
4
+ /**
5
+ * Adopt a live app and its database in place (archon#271). The caller names
6
+ * what already runs in Coolify; foundry reads it, checks it is what it would
7
+ * have created for the entry, and records the ids so the next plan finds them
8
+ * instead of planning a create. Nothing here calls Coolify to change anything.
9
+ *
10
+ * No redeploy happens at adopt. The next normal plan may still propose
11
+ * updates to the adopted app (log volume, env push, hostnames, labels) because
12
+ * foundry's applied markers are absent; applying those redeploys the app. The
13
+ * host must show that plan to the operator before it is applied.
14
+ */
15
+ export interface AdoptInput {
16
+ /** The declared hosting entry (`HostingEntry.key`) that takes the app over. It must have `database: true`. */
17
+ entryKey: string;
18
+ /** The Coolify application uuid. */
19
+ applicationUuid: string;
20
+ /** The Coolify Postgres database uuid. */
21
+ databaseUuid: string;
22
+ /** The Coolify project both live in. Optional when the org already records one. */
23
+ projectUuid?: string;
24
+ }
25
+ /** What the adopted resources looked like when adopted: the reader's output, never a secret. */
26
+ export interface AdoptSnapshot {
27
+ adoptedAt: string;
28
+ projectUuid: string;
29
+ /** True when adopt recorded `coolify_project` itself, so release takes it out again. */
30
+ projectRecorded: boolean;
31
+ application: {
32
+ uuid: string;
33
+ config: Record<string, unknown>;
34
+ };
35
+ database: {
36
+ uuid: string;
37
+ config: Record<string, unknown>;
38
+ };
39
+ }
40
+ /** The flat scope key (no `:`) holding an entry's {@link AdoptSnapshot} as JSON. Its presence means "adopted". */
41
+ export declare function adoptedScopeKey(entryKey: string): string;
42
+ /** `null` when absent, or when it is not a snapshot (callers that delete must treat the latter as an error: see {@link adoptedKeysUnreadable}). */
43
+ export declare function parseAdoptSnapshot(raw: string | undefined): AdoptSnapshot | null;
44
+ /** The `adopted.<entry>` keys of `serviceIds` whose value is not a readable snapshot. */
45
+ export declare function adoptedKeysUnreadable(serviceIds: Readonly<Record<string, string>>): string[];
46
+ export declare function snapshotConfig(config: Record<string, unknown>): Record<string, unknown>;
47
+ export declare class AdoptRefusedError extends Error {
48
+ readonly reasons: string[];
49
+ constructor(reasons: string[]);
50
+ }
51
+ export interface AdoptPlan {
52
+ plan: Plan;
53
+ /** Empty when the live resources match the entry. A non-empty list refuses `adopt` and the plan has no steps. */
54
+ mismatches: string[];
55
+ /** What `adopt` would record; absent with mismatches. */
56
+ snapshot?: AdoptSnapshot;
57
+ }
58
+ /** A live app or database in the project that this org has not recorded, for Archon's "Live, not theirs". */
59
+ export interface AdoptCandidate {
60
+ kind: 'application' | 'database';
61
+ uuid: string;
62
+ name: string;
63
+ status?: string;
64
+ /** True when its name is the one foundry would have given it for the entry. */
65
+ matchesName: boolean;
66
+ }
67
+ /** Live applications and databases in the project that no recorded id of this org names. */
68
+ export declare function adoptCandidates(org: DeclaredOrg, entryKey: string, recordedIds: Readonly<Record<string, string>>, live: readonly LiveResource[]): AdoptCandidate[];
69
+ /**
70
+ * Checks `input` against the declaration, the recorded ids and the live
71
+ * resources of the project, and builds the plan steps. `live` is the reader's
72
+ * output for the project (`null` when the reader cannot read a project).
73
+ */
74
+ export declare function planAdoptFrom(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, input: AdoptInput, live: readonly LiveResource[] | null, now: Date,
75
+ /** What another org already records among the three uuids, one reason each (`FoundryPlanApply.computeAdopt`). */
76
+ claimedElsewhere?: readonly string[]): AdoptPlan;