@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.
@@ -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
- plannedSteps.push(...planIssuerApps(org, byKey, claimed));
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
- return 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)`);
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
- return planHostingToggle('cloudflare', 'tunnel', org.hosting.tunnel, 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)`);
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
+ }