@wtfalch/foundry 0.2.0 → 0.3.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.
- package/dist/calls-sfu-deploy-support.d.ts +57 -0
- package/dist/calls-sfu-deploy-support.js +82 -0
- package/dist/calls-sfu.d.ts +175 -0
- package/dist/calls-sfu.js +239 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.js +6 -4
- package/dist/migrate.d.ts +38 -0
- package/dist/migrate.js +47 -0
- package/dist/migrations/0012_audit.sql +21 -0
- package/dist/migrations/0013_access_emails.sql +6 -0
- package/dist/org.d.ts +23 -0
- package/dist/org.js +37 -0
- package/dist/plan/diff.js +136 -5
- package/dist/plan/executor.d.ts +363 -14
- package/dist/plan/executor.js +759 -24
- package/dist/plan/index.d.ts +2 -2
- package/dist/plan/index.js +2 -2
- package/dist/plan/plan-apply.js +17 -2
- package/dist/plan/providers-readers.d.ts +54 -1
- package/dist/plan/providers-readers.js +153 -1
- package/dist/provision.d.ts +56 -0
- package/dist/provision.js +69 -0
- package/dist/slug.d.ts +12 -18
- package/dist/slug.js +66 -0
- package/dist/store/audit.d.ts +8 -0
- package/dist/store/audit.js +43 -0
- package/dist/store/postgres.js +20 -1
- package/dist/store/schema.d.ts +188 -0
- package/dist/store/schema.js +14 -1
- package/dist/store.d.ts +2 -0
- package/package.json +9 -3
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
// #R2 (map gap: foundry's own Postgres, two commands): a tracked, idempotent
|
|
5
|
+
// migration runner. `./test/db.ts` and `./runner/test/db.ts` already read
|
|
6
|
+
// and sort this same directory to build one concatenated blob for a fresh
|
|
7
|
+
// PGlite -- fine for a test fixture that always starts empty, wrong for a
|
|
8
|
+
// real host that must apply only what a `foundry_schema_migrations` table
|
|
9
|
+
// does not yet list. `migrationFiles` below is that same discovery, reused
|
|
10
|
+
// rather than re-implemented, kept per-file instead of concatenated.
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
/** This package's own migrations -- `../migrations` from `src/`, `./migrations` once built to `dist/` (`package.json`'s `build` script copies the `.sql` files there alongside `migrate.js`). */
|
|
13
|
+
export const MIGRATIONS_DIR = join(here, 'migrations');
|
|
14
|
+
const TRACKING_TABLE = 'foundry_schema_migrations';
|
|
15
|
+
function migrationFiles(dir) {
|
|
16
|
+
return readdirSync(dir)
|
|
17
|
+
.filter((f) => /^\d{4}_.+\.sql$/.test(f))
|
|
18
|
+
.sort()
|
|
19
|
+
.map((name) => ({ name, path: join(dir, name) }));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Applies every migration file in `dir` (default: this package's own
|
|
23
|
+
* `../migrations`) not yet recorded in `foundry_schema_migrations`, in
|
|
24
|
+
* filename order, one transaction per file. Safe to call repeatedly: a file
|
|
25
|
+
* already recorded is never re-read, let alone re-run.
|
|
26
|
+
*/
|
|
27
|
+
export async function migrate(sql, dir = MIGRATIONS_DIR) {
|
|
28
|
+
await sql.exec(`create table if not exists ${TRACKING_TABLE} (name text primary key, applied_at timestamptz not null default now())`);
|
|
29
|
+
const appliedRows = await sql.query(`select name from ${TRACKING_TABLE}`);
|
|
30
|
+
const applied = new Set(appliedRows.map((row) => row.name));
|
|
31
|
+
const pending = migrationFiles(dir).filter((file) => !applied.has(file.name));
|
|
32
|
+
const done = [];
|
|
33
|
+
for (const file of pending) {
|
|
34
|
+
const text = readFileSync(file.path, 'utf8');
|
|
35
|
+
await sql.transaction(async (tx) => {
|
|
36
|
+
await tx.exec(text);
|
|
37
|
+
// `file.name` is drawn from `migrationFiles`' own regex-filtered
|
|
38
|
+
// `readdirSync`, never from outside this module -- see `MigrateSql`'s
|
|
39
|
+
// doc comment. Quotes are still escaped, so an oddly-named future
|
|
40
|
+
// migration file cannot break the statement.
|
|
41
|
+
const escaped = file.name.replace(/'/g, "''");
|
|
42
|
+
await tx.exec(`insert into ${TRACKING_TABLE} (name) values ('${escaped}')`);
|
|
43
|
+
});
|
|
44
|
+
done.push(file.name);
|
|
45
|
+
}
|
|
46
|
+
return { applied: done };
|
|
47
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- @wtfalch/foundry: who planned, applied or destroyed which org, and how it
|
|
2
|
+
-- went. Mirrors `src/store/schema.ts`'s `foundryAuditEvents`.
|
|
3
|
+
--
|
|
4
|
+
-- Decided on #85: before any archon exists, the foundry CLI records here. An
|
|
5
|
+
-- attached archon reads this table later. Rows are only ever inserted.
|
|
6
|
+
-- `org_id` has no foreign key, so a destroyed org's trail survives it.
|
|
7
|
+
|
|
8
|
+
create table if not exists foundry_audit_events (
|
|
9
|
+
id bigserial primary key,
|
|
10
|
+
at timestamptz not null,
|
|
11
|
+
kind text not null check (kind in ('plan', 'apply', 'destroy')),
|
|
12
|
+
actor_id text not null,
|
|
13
|
+
actor_display text not null,
|
|
14
|
+
org_id text not null,
|
|
15
|
+
outcome text check (outcome in ('applied', 'refused', 'failed', 'destroyed')),
|
|
16
|
+
reason text,
|
|
17
|
+
plan jsonb
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
create index if not exists foundry_audit_events_org_at
|
|
21
|
+
on foundry_audit_events (org_id, at);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- @wtfalch/foundry: emails Cloudflare Access lets through to the org's app
|
|
2
|
+
-- (#84). Mirrors `src/store/schema.ts`'s `hostingAccessEmails`. An empty list
|
|
3
|
+
-- means no Access gate, so every existing org keeps its current behaviour.
|
|
4
|
+
|
|
5
|
+
alter table foundry_declared_orgs
|
|
6
|
+
add column if not exists hosting_access_emails jsonb not null default '[]'::jsonb;
|
package/dist/org.d.ts
CHANGED
|
@@ -147,6 +147,11 @@ export interface HostingConfig {
|
|
|
147
147
|
buildPack: HostingBuildPack;
|
|
148
148
|
/** The org's declared ZITADEL OIDC applications (#71). */
|
|
149
149
|
issuerApps: IssuerApp[];
|
|
150
|
+
/**
|
|
151
|
+
* Emails Cloudflare Access lets through to the app's hostname (#84). Empty
|
|
152
|
+
* means no Access gate. Requires `enabled`.
|
|
153
|
+
*/
|
|
154
|
+
accessEmails: string[];
|
|
150
155
|
}
|
|
151
156
|
/**
|
|
152
157
|
* `null` when `hosting` is valid; otherwise the reason it is not (#71), same
|
|
@@ -160,6 +165,24 @@ export interface HostingConfig {
|
|
|
160
165
|
* list, and a `kind` that is one of `IssuerAppKind`'s three values.
|
|
161
166
|
*/
|
|
162
167
|
export declare function validateHosting(hosting: HostingConfig): string | null;
|
|
168
|
+
/**
|
|
169
|
+
* `null` when `site` is valid; otherwise the reason it is not (#101).
|
|
170
|
+
*
|
|
171
|
+
* Decided 2026-09-25
|
|
172
|
+
* (https://github.com/wtfalch/cms/blob/main/.plans/2026-09-25-org-sites.md):
|
|
173
|
+
* a cms site is how an org puts content on its own domain, and an org
|
|
174
|
+
* either runs its own app on its domain (`hosting`) or we host its website
|
|
175
|
+
* and cms there (`site`), never both -- both would otherwise fight over the
|
|
176
|
+
* same hostname (https://github.com/wtfalch/foundry/issues/99).
|
|
177
|
+
*
|
|
178
|
+
* - `site.enabled` requires `domain` to be set -- a site with nowhere to
|
|
179
|
+
* live is not a site.
|
|
180
|
+
* - `site.enabled` requires `site.usesDomain` -- unlike mail, chat, calls
|
|
181
|
+
* and the issuer, the site has no meaning anywhere but the org's own
|
|
182
|
+
* domain, so turning it on always means the domain opt-in too.
|
|
183
|
+
* - `site.enabled` and `hosting.enabled` cannot both be on.
|
|
184
|
+
*/
|
|
185
|
+
export declare function validateSite(org: DeclaredOrg): string | null;
|
|
163
186
|
/**
|
|
164
187
|
* Per-org SSO. Decided to exist in #14 ("Also in scope") and its resolved
|
|
165
188
|
* research #3 ("per-org SSO code exists unused"); nothing about its
|
package/dist/org.js
CHANGED
|
@@ -29,6 +29,14 @@ export function validateHosting(hosting) {
|
|
|
29
29
|
if (!HOSTING_BUILD_PACKS.has(hosting.buildPack)) {
|
|
30
30
|
return `hosting.buildPack has an unknown value '${hosting.buildPack}'`;
|
|
31
31
|
}
|
|
32
|
+
if (hosting.accessEmails.length > 0 && !hosting.enabled) {
|
|
33
|
+
return 'hosting.accessEmails requires hosting.enabled';
|
|
34
|
+
}
|
|
35
|
+
for (const email of hosting.accessEmails) {
|
|
36
|
+
if (!/^[^@\s]+@[^@\s]+$/.test(email)) {
|
|
37
|
+
return `hosting.accessEmails has an invalid email '${email}'`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
32
40
|
const seenNames = new Set();
|
|
33
41
|
for (const app of hosting.issuerApps) {
|
|
34
42
|
if (!app.name) {
|
|
@@ -44,6 +52,35 @@ export function validateHosting(hosting) {
|
|
|
44
52
|
}
|
|
45
53
|
return null;
|
|
46
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* `null` when `site` is valid; otherwise the reason it is not (#101).
|
|
57
|
+
*
|
|
58
|
+
* Decided 2026-09-25
|
|
59
|
+
* (https://github.com/wtfalch/cms/blob/main/.plans/2026-09-25-org-sites.md):
|
|
60
|
+
* a cms site is how an org puts content on its own domain, and an org
|
|
61
|
+
* either runs its own app on its domain (`hosting`) or we host its website
|
|
62
|
+
* and cms there (`site`), never both -- both would otherwise fight over the
|
|
63
|
+
* same hostname (https://github.com/wtfalch/foundry/issues/99).
|
|
64
|
+
*
|
|
65
|
+
* - `site.enabled` requires `domain` to be set -- a site with nowhere to
|
|
66
|
+
* live is not a site.
|
|
67
|
+
* - `site.enabled` requires `site.usesDomain` -- unlike mail, chat, calls
|
|
68
|
+
* and the issuer, the site has no meaning anywhere but the org's own
|
|
69
|
+
* domain, so turning it on always means the domain opt-in too.
|
|
70
|
+
* - `site.enabled` and `hosting.enabled` cannot both be on.
|
|
71
|
+
*/
|
|
72
|
+
export function validateSite(org) {
|
|
73
|
+
if (org.site.enabled && !org.domain) {
|
|
74
|
+
return 'site.enabled requires domain';
|
|
75
|
+
}
|
|
76
|
+
if (org.site.enabled && !org.site.usesDomain) {
|
|
77
|
+
return 'site.enabled requires site.usesDomain';
|
|
78
|
+
}
|
|
79
|
+
if (org.site.enabled && org.hosting.enabled) {
|
|
80
|
+
return 'site.enabled and hosting.enabled cannot both be on';
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
47
84
|
const ORG_NUMBER_PATTERN = /^\d{9}$/;
|
|
48
85
|
/**
|
|
49
86
|
* `null` when `buyer` is valid; otherwise the reason it is not, for a caller
|
package/dist/plan/diff.js
CHANGED
|
@@ -73,6 +73,18 @@ export function computePlanDetail(org, live, recordedIds = {}) {
|
|
|
73
73
|
const site = planSite(org, byKey, claimed);
|
|
74
74
|
if (site)
|
|
75
75
|
plannedSteps.push(site);
|
|
76
|
+
// Every declared ZITADEL app, before the app cluster below (level-1
|
|
77
|
+
// app-env campaign, gap P2): `coolifyApp`'s own env push
|
|
78
|
+
// (`executor.ts`'s `pushAppEnv`) reads a web app's `clientId` to set
|
|
79
|
+
// `AUTH_CLIENT_ID`, which only exists once that app's own `zitadel:app`
|
|
80
|
+
// step has run -- `plan-apply.ts`'s `runSteps` executes `plannedSteps` in
|
|
81
|
+
// this array order, one at a time.
|
|
82
|
+
plannedSteps.push(...planIssuerApps(org, byKey, claimed));
|
|
83
|
+
// The Access gate before the database, app, tunnel and zone (#84): on a
|
|
84
|
+
// first apply the hostname is gated before anything answers on it.
|
|
85
|
+
const access = planAccess(org, recordedIds);
|
|
86
|
+
if (access)
|
|
87
|
+
plannedSteps.push(access);
|
|
76
88
|
// Database before app (hosting-wiring campaign): `plan-apply.ts`'s
|
|
77
89
|
// `runSteps` executes `plannedSteps` in this array order, and the app's own
|
|
78
90
|
// step (`executor.ts`'s `coolifyApp`) pushes the database's connection
|
|
@@ -91,7 +103,12 @@ export function computePlanDetail(org, live, recordedIds = {}) {
|
|
|
91
103
|
const zone = planZone(org, byKey, claimed);
|
|
92
104
|
if (zone)
|
|
93
105
|
plannedSteps.push(zone);
|
|
94
|
-
|
|
106
|
+
// Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
|
|
107
|
+
// apex domain, so the zone step above must already have run (or the zone
|
|
108
|
+
// must already exist live) by the time this one's action executes.
|
|
109
|
+
const rateLimit = planRateLimit(org, recordedIds);
|
|
110
|
+
if (rateLimit)
|
|
111
|
+
plannedSteps.push(rateLimit);
|
|
95
112
|
// After the apps: the customers org is granted the project the first app
|
|
96
113
|
// step creates.
|
|
97
114
|
const customers = planCustomers(org, live, claimed, recordedIds);
|
|
@@ -562,9 +579,42 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
|
|
|
562
579
|
* The org's own app on Coolify (`org.ts`'s `HostingConfig.enabled`, #71), a
|
|
563
580
|
* per-org singleton resource modeled on {@link planSite}: park, not delete,
|
|
564
581
|
* on the way off (#7 corrections 1).
|
|
582
|
+
*
|
|
583
|
+
* #75: every app foundry deploys also carries a durable log directory (a
|
|
584
|
+
* persistent volume mounted at `/app/logs`, `LOG_DIR` in its env) so a
|
|
585
|
+
* database outage does not erase the only other copy of the evidence
|
|
586
|
+
* (`executor.ts`'s `ensureLogVolume`/`pushAppEnv`, lokessmie's own pilot at
|
|
587
|
+
* https://github.com/wtfalch/lokessmie/pull/521). `planHostingToggle` above
|
|
588
|
+
* only tracks on/off, so once it finds nothing to toggle -- app live and
|
|
589
|
+
* already enabled -- this planner adds one more check on top: a live app
|
|
590
|
+
* that predates this feature (`live.config.hasLogVolume === false`,
|
|
591
|
+
* `providers-readers.ts`'s `withLogVolumeConfig`) is planned for it, reusing
|
|
592
|
+
* the same `coolify:app`/`enabled: true` action `planHostingToggle`'s own
|
|
593
|
+
* provision step uses -- `executor.ts`'s `coolifyApp` already runs
|
|
594
|
+
* `ensureLogVolume` unconditionally on that action, whether it just created
|
|
595
|
+
* the app or found it already there, so no new step kind is needed. A
|
|
596
|
+
* missing `hasLogVolume` (rather than an explicit `false`) reads as
|
|
597
|
+
* compliant, the same convention `planHostingToggle` itself uses for a live
|
|
598
|
+
* resource's `enabled` field -- a live resource nothing has actually
|
|
599
|
+
* checked yet is assumed fine rather than replanned every time.
|
|
565
600
|
*/
|
|
566
601
|
function planApp(org, byKey, claimed) {
|
|
567
|
-
|
|
602
|
+
const toggled = planHostingToggle('coolify', 'app', org.hosting.enabled, org, byKey, claimed, () => `provision ${org.slug}'s app`, () => `park ${org.slug}'s app (stops serving; app data is retained, not deleted -- #7 corrections 1)`);
|
|
603
|
+
if (toggled)
|
|
604
|
+
return toggled;
|
|
605
|
+
if (!org.hosting.enabled)
|
|
606
|
+
return null;
|
|
607
|
+
const live = byKey.get(resourceKey('coolify', 'app', org.id));
|
|
608
|
+
if (!live || live.config.hasLogVolume !== false)
|
|
609
|
+
return null;
|
|
610
|
+
return {
|
|
611
|
+
action: { system: 'coolify', kind: 'app', key: org.id, desired: { enabled: true } },
|
|
612
|
+
step: {
|
|
613
|
+
description: `add a durable log directory to ${org.slug}'s app (persistent volume at /app/logs + LOG_DIR) so a database outage does not erase the evidence (#75)`,
|
|
614
|
+
destroys: false,
|
|
615
|
+
migrates: false,
|
|
616
|
+
},
|
|
617
|
+
};
|
|
568
618
|
}
|
|
569
619
|
/**
|
|
570
620
|
* A Postgres database beside the org's app (`org.ts`'s
|
|
@@ -579,10 +629,25 @@ function planDatabase(org, byKey, claimed) {
|
|
|
579
629
|
* A Cloudflare tunnel reaching the org's app (`org.ts`'s
|
|
580
630
|
* `HostingConfig.tunnel`, #71) -- `validateHosting` requires
|
|
581
631
|
* `hosting.enabled` before this can be true. Same park-not-delete shape as
|
|
582
|
-
* {@link planApp}
|
|
632
|
+
* {@link planApp}, gated on the same `usesDomain`+`domain` pair
|
|
633
|
+
* {@link planZone} checks: a level-1 org (no own domain, served at
|
|
634
|
+
* `<slug>.oikos.host`) gets no per-org tunnel at all -- one shared tunnel
|
|
635
|
+
* with a wildcard `*.oikos.host` record serves every level-1 org instead
|
|
636
|
+
* (William, 2026-09-26, map gap N3). The gate folds into the `desired`
|
|
637
|
+
* flag rather than an early return, so an org that drops its domain while
|
|
638
|
+
* a per-org tunnel is still live gets that tunnel parked, not left
|
|
639
|
+
* unmanaged, through `planHostingToggle`'s own park path.
|
|
640
|
+
*
|
|
641
|
+
* https://github.com/wtfalch/foundry/issues/115: a site-enabled org always
|
|
642
|
+
* needs its tunnel too, routing both its public site and its cms studio
|
|
643
|
+
* (`executor.ts`'s `tunnelIngressHostnames`) -- there is no separate
|
|
644
|
+
* `site.tunnel` toggle to gate on the way `hosting.tunnel` does, since a site
|
|
645
|
+
* has no meaning without one.
|
|
583
646
|
*/
|
|
584
647
|
function planTunnel(org, byKey, claimed) {
|
|
585
|
-
|
|
648
|
+
const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
|
|
649
|
+
(org.site.enabled && Boolean(org.domain));
|
|
650
|
+
return planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`);
|
|
586
651
|
}
|
|
587
652
|
/**
|
|
588
653
|
* The org's Cloudflare zone for its own domain (`org.ts`'s
|
|
@@ -593,9 +658,15 @@ function planTunnel(org, byKey, claimed) {
|
|
|
593
658
|
* foundry should stop managing a domain the org may still own elsewhere, so
|
|
594
659
|
* a live zone under a not-`usesDomain` org is left unmanaged rather than
|
|
595
660
|
* claimed -- the same choice {@link planMail} makes for a `mode: 'off'` org.
|
|
661
|
+
*
|
|
662
|
+
* https://github.com/wtfalch/foundry/issues/115: a site-enabled org
|
|
663
|
+
* (`org.ts`'s `SiteConfig.enabled`) also needs a zone for its domain, even
|
|
664
|
+
* though it can never have `hosting` on at the same time (`validateSite`) --
|
|
665
|
+
* `org.site.enabled` already guarantees both `domain` and `site.usesDomain`
|
|
666
|
+
* are set, so the `org.domain` check below still covers it.
|
|
596
667
|
*/
|
|
597
668
|
function planZone(org, byKey, claimed) {
|
|
598
|
-
if (!org.hosting.usesDomain || !org.domain)
|
|
669
|
+
if (!(org.hosting.usesDomain || org.site.enabled) || !org.domain)
|
|
599
670
|
return null;
|
|
600
671
|
const key = resourceKey('cloudflare', 'zone', org.domain);
|
|
601
672
|
const live = byKey.get(key);
|
|
@@ -693,3 +764,63 @@ function planCustomers(org, live, claimed, recordedIds) {
|
|
|
693
764
|
},
|
|
694
765
|
};
|
|
695
766
|
}
|
|
767
|
+
/**
|
|
768
|
+
* #84: a Cloudflare Access gate on the org's app hostname, letting only
|
|
769
|
+
* `hosting.accessEmails` through. Idempotent on the recorded Access app id,
|
|
770
|
+
* like `planOwner`: Cloudflare's live listing has no Access apps. A later
|
|
771
|
+
* change to the email list is not reconciled yet.
|
|
772
|
+
*/
|
|
773
|
+
function planAccess(org, recordedIds) {
|
|
774
|
+
if (!org.hosting.enabled || org.hosting.accessEmails.length === 0)
|
|
775
|
+
return null;
|
|
776
|
+
if (recordedIds[resourceKey('cloudflare', 'access', org.id)])
|
|
777
|
+
return null;
|
|
778
|
+
return {
|
|
779
|
+
action: { system: 'cloudflare', kind: 'access', key: org.id, desired: {} },
|
|
780
|
+
step: {
|
|
781
|
+
description: `put ${org.slug}'s app behind Cloudflare Access`,
|
|
782
|
+
destroys: false,
|
|
783
|
+
migrates: false,
|
|
784
|
+
},
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* https://github.com/wtfalch/foundry/issues/43: one Cloudflare rate-limiting
|
|
789
|
+
* rule on the org's own app hostname's sign-in paths (`@wtfalch/auth`'s
|
|
790
|
+
* `NextAuth.handlers`, mounted at `app/auth/[...auth]/route.ts` -- every
|
|
791
|
+
* sign-in request passes through `/auth/*`, `executor.ts`'s
|
|
792
|
+
* `cloudflareRateLimit`). Gated on the same `usesDomain`/`site.enabled`
|
|
793
|
+
* +`domain` pair {@link planZone} and {@link planTunnel} check (widened for a
|
|
794
|
+
* site-enabled org by #115: the cms studio signs in under `/auth/` too, on
|
|
795
|
+
* the same zone {@link planZone} provisions for it): only an own-domain org
|
|
796
|
+
* gets this step. A level-1 org (no own domain, served at
|
|
797
|
+
* `<slug>.oikos.host`) shares the `oikos.host` zone with every other level-1
|
|
798
|
+
* org, and Cloudflare's Free plan allows exactly one rate limiting rule per
|
|
799
|
+
* zone (developers.cloudflare.com/waf/rate-limiting-rules/, checked
|
|
800
|
+
* 2026-09-26) -- a per-org step here would fight every other level-1 org's
|
|
801
|
+
* for that one slot the moment a second org applied. The shared zone instead
|
|
802
|
+
* gets one rule covering every level-1 org at once, provisioned by
|
|
803
|
+
* `scripts/shared-ingress.mjs`, not by this planner (#43's own task brief).
|
|
804
|
+
*
|
|
805
|
+
* Idempotent on the recorded rule id, like {@link planAccess}: rate limiting
|
|
806
|
+
* rules are not part of any system's `listResources` (`@wtfalch/providers`
|
|
807
|
+
* 0.5.0 has no typed reader for them, only its generic `request` escape
|
|
808
|
+
* hatch -- `executor.ts`'s `CloudflarePort.request` doc comment), so there is
|
|
809
|
+
* no live resource to diff against here. Planned right after {@link planZone}
|
|
810
|
+
* so the zone `cloudflareRateLimit` needs to resolve always exists by the
|
|
811
|
+
* time this step's own action runs.
|
|
812
|
+
*/
|
|
813
|
+
function planRateLimit(org, recordedIds) {
|
|
814
|
+
if (!(org.hosting.usesDomain || org.site.enabled) || !org.domain)
|
|
815
|
+
return null;
|
|
816
|
+
if (recordedIds[resourceKey('cloudflare', 'ratelimit', org.id)])
|
|
817
|
+
return null;
|
|
818
|
+
return {
|
|
819
|
+
action: { system: 'cloudflare', kind: 'ratelimit', key: org.id, desired: {} },
|
|
820
|
+
step: {
|
|
821
|
+
description: `rate limit ${org.slug}'s sign-in paths on Cloudflare (free-plan allowance: one rule per zone)`,
|
|
822
|
+
destroys: false,
|
|
823
|
+
migrates: false,
|
|
824
|
+
},
|
|
825
|
+
};
|
|
826
|
+
}
|