@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.
- package/dist/apply.d.ts +22 -1
- package/dist/bootstrap/bootstrap.d.ts +6 -1
- package/dist/bootstrap/bootstrap.js +26 -3
- package/dist/bootstrap/declare.d.ts +11 -1
- package/dist/bootstrap/declare.js +18 -6
- package/dist/index.d.ts +11 -6
- package/dist/index.js +8 -1
- package/dist/migrations/0038_calls_version_pin.sql +8 -0
- package/dist/migrations/0039_destroy_record.sql +7 -0
- package/dist/migrations/0040_company_included_uuid.sql +72 -0
- package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
- package/dist/migrations/0042_mutation_leases.sql +12 -0
- package/dist/migrations/0043_platform_names.sql +25 -0
- package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
- package/dist/org.d.ts +128 -3
- package/dist/org.js +186 -1
- package/dist/plan/adopt.d.ts +76 -0
- package/dist/plan/adopt.js +184 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +37 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +13 -5
- package/dist/plan/chat-matrix.d.ts +1 -2
- package/dist/plan/chat-matrix.js +15 -4
- package/dist/plan/core-files.d.ts +25 -0
- package/dist/plan/core-files.js +25 -0
- package/dist/plan/diff.d.ts +27 -3
- package/dist/plan/diff.js +264 -86
- package/dist/plan/edge-worker.d.ts +114 -0
- package/dist/plan/edge-worker.js +201 -0
- package/dist/plan/entry-ai-key.d.ts +3 -3
- package/dist/plan/entry-files-key.d.ts +3 -3
- package/dist/plan/entry-names-key.d.ts +73 -0
- package/dist/plan/entry-names-key.js +236 -0
- package/dist/plan/executor.d.ts +292 -24
- package/dist/plan/executor.js +745 -77
- package/dist/plan/index.d.ts +6 -2
- package/dist/plan/index.js +3 -1
- package/dist/plan/live.d.ts +9 -2
- package/dist/plan/login-host.d.ts +13 -2
- package/dist/plan/login-host.js +26 -7
- package/dist/plan/plan-apply.d.ts +153 -6
- package/dist/plan/plan-apply.js +639 -78
- package/dist/plan/platform-diff.d.ts +106 -1
- package/dist/plan/platform-diff.js +182 -2
- package/dist/plan/platform-org.d.ts +45 -1
- package/dist/plan/platform-org.js +34 -3
- package/dist/plan/platform-providers.d.ts +45 -1
- package/dist/plan/platform-providers.js +585 -16
- package/dist/plan/providers-readers.d.ts +2 -0
- package/dist/plan/providers-readers.js +4 -0
- package/dist/plan/rollout.d.ts +52 -0
- package/dist/plan/rollout.js +37 -0
- package/dist/plan/step-system.d.ts +22 -0
- package/dist/plan/step-system.js +38 -0
- package/dist/plan.d.ts +3 -0
- package/dist/platform.d.ts +22 -3
- package/dist/platform.js +6 -0
- package/dist/state.d.ts +13 -0
- package/dist/store/audit.js +15 -1
- package/dist/store/postgres.d.ts +1 -1
- package/dist/store/postgres.js +142 -15
- package/dist/store/schema.d.ts +202 -5
- package/dist/store/schema.js +18 -1
- package/dist/store.d.ts +47 -4
- package/dist/store.js +9 -1
- 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
|
|
487
|
-
*
|
|
488
|
-
*
|
|
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;
|