@wtfalch/foundry 0.16.7 → 0.16.8

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.
@@ -4,6 +4,8 @@ import type { FoundryStoreDb } from '../store/postgres.js';
4
4
  import type { BootstrapCompany, BootstrapOwner } from './declare.js';
5
5
  export interface BootstrapPlatformInput {
6
6
  company: BootstrapCompany;
7
+ /** The Coolify server's public IPv4, saved as `platform.serverIpv4` (`../platform.ts`); absent leaves a stored value alone. */
8
+ serverIpv4?: string;
7
9
  owner: BootstrapOwner;
8
10
  }
9
11
  /**
@@ -198,9 +198,17 @@ export async function bootstrapPlatform(input, deps) {
198
198
  const existing = await platformStore.get();
199
199
  if (existing) {
200
200
  emit({ phase: 'declare', message: `declared platform already written for ${existing.apex}` });
201
+ // Only serverIpv4, through the validating `put`; no other field is touched.
202
+ if (input.serverIpv4 && input.serverIpv4 !== existing.serverIpv4) {
203
+ await platformStore.put({ ...existing, serverIpv4: input.serverIpv4 });
204
+ emit({
205
+ phase: 'declare',
206
+ message: `declared platform serverIpv4 set to ${input.serverIpv4}`,
207
+ });
208
+ }
201
209
  }
202
210
  else {
203
- await platformStore.put(declaredPlatformFor(input.company.apex));
211
+ await platformStore.put(declaredPlatformFor(input.company.apex, input.serverIpv4));
204
212
  emit({
205
213
  phase: 'declare',
206
214
  message: `declared platform written for ${input.company.apex}`,
@@ -35,4 +35,4 @@ export declare function declaredCompanyOrg(company: BootstrapCompany, owner: Boo
35
35
  * The declared platform under `apex`. A disabled service keeps an empty
36
36
  * `gitRef`, which `validatePlatform` allows.
37
37
  */
38
- export declare function declaredPlatformFor(apex: string): DeclaredPlatform;
38
+ export declare function declaredPlatformFor(apex: string, serverIpv4?: string): DeclaredPlatform;
@@ -85,7 +85,7 @@ export function declaredCompanyOrg(company, owner) {
85
85
  * The declared platform under `apex`. A disabled service keeps an empty
86
86
  * `gitRef`, which `validatePlatform` allows.
87
87
  */
88
- export function declaredPlatformFor(apex) {
88
+ export function declaredPlatformFor(apex, serverIpv4) {
89
89
  const services = {};
90
90
  for (const key of PLATFORM_SERVICE_KEYS) {
91
91
  const enabled = SERVICES_ENABLED_BY_DEFAULT.has(key);
@@ -95,5 +95,10 @@ export function declaredPlatformFor(apex) {
95
95
  gitRef: !enabled ? '' : key === 'mail' ? DEFAULT_MAIL_IMAGE_TAG : DEFAULT_GIT_REF,
96
96
  };
97
97
  }
98
- return { ownerOrgId: COMPANY_INCLUDED_ORG_ID, apex, ...services };
98
+ return {
99
+ ownerOrgId: COMPANY_INCLUDED_ORG_ID,
100
+ apex,
101
+ ...(serverIpv4 ? { serverIpv4 } : {}),
102
+ ...services,
103
+ };
99
104
  }
package/dist/index.d.ts CHANGED
@@ -8,12 +8,12 @@ export type { SlugRejectionReason, SlugRejection } from './slug.js';
8
8
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
9
9
  export type { Checkpoint, LastApplied, AppliedState } from './state.js';
10
10
  export type { PlanStep, UnmanagedItem, Plan } from './plan.js';
11
- export type { DeclaredOrgStore, PlatformStore, RequestedChangeStore, StaleUpdate, UpdateOptions, } from './store.js';
11
+ export type { AppliedVersion, AppliedVersionsStore, DeclaredOrgStore, PlatformStore, RequestedChangeStore, StaleUpdate, UpdateOptions, } from './store.js';
12
12
  export type { PlanApply } from './apply.js';
13
- export { createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
13
+ export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
14
14
  export { createPostgresAuditLog } from './store/audit.js';
15
15
  export type { FoundryStoreDb } from './store/postgres.js';
16
- export { declaredOrgs, foundryAppliedState, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
16
+ export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type AppliedVersionRow, type NewAppliedVersionRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
17
17
  export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, PlanDetail, PlatformPlanDetail, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
18
18
  export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
19
19
  export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
package/dist/index.js CHANGED
@@ -2,9 +2,9 @@ export { APP_ENTRY_KEY, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_M
2
2
  export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
3
3
  export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
4
4
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
5
- export { createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
5
+ export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
6
6
  export { createPostgresAuditLog } from './store/audit.js';
7
- export { declaredOrgs, foundryAppliedState, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
7
+ export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
8
8
  export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
9
9
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
10
10
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
@@ -0,0 +1,45 @@
1
+ -- @wtfalch/foundry: a history of recorded applies, sitting beside
2
+ -- `0002_applied_state.sql`'s single current snapshot (#253, building on the
3
+ -- upgrade-safety map, #237). `foundry_applied_state` (and the platform's own
4
+ -- `foundry_declared_platform.service_ids`) replace their one row in place on
5
+ -- every apply, so once a new version is recorded the one it replaced is
6
+ -- gone -- there is no "previous version" to show or to roll back to, and a
7
+ -- failed apply leaves no trail of what came before it either. This table
8
+ -- never replaces a row; every recorded version is a new one, appended.
9
+ --
10
+ -- `service` names whatever was just recorded as applied -- today, an org's
11
+ -- id, from the same point in `plan/plan-apply.ts`'s `runSteps` that writes
12
+ -- `AppliedState.lastApplied` (mirrors `0002`'s `last_applied_declared_org_version`
13
+ -- renamed generically, since #237's platform services -- mail, archon, ai,
14
+ -- files, integrations and the rest -- record an applied ref of their own and
15
+ -- can write rows here the same way once they do). `ref` is whatever version
16
+ -- identifier that apply recorded (an org's declared-org hash today; a git
17
+ -- ref or image tag for a platform service).
18
+ --
19
+ -- `run_id` and `backup_id` are both nullable: nothing in today's code ties
20
+ -- an apply to a run id (the job runner, `src/runner`, is not yet wired to
21
+ -- plan/apply -- #17's own module, standing alone), and no apply takes a
22
+ -- backup before recording a version yet -- #256 ("Back up before a stateful
23
+ -- upgrade") is the later issue that starts writing `backup_id`. Both columns
24
+ -- exist now so that issue, and a future run-id wiring, are additive: a new
25
+ -- column would be, this is a new row shape instead.
26
+ --
27
+ -- Read back by `previousAppliedRef` (`../store.ts`): the ref of the
28
+ -- second-most-recent row for a service is what a rollback has to roll back
29
+ -- to. No foreign key to `foundry_declared_orgs` -- `foundry_runs.org_id`
30
+ -- makes the same choice (`0003_foundry_runs.sql`) -- because `service` is
31
+ -- not always an org id, and a history row for a destroyed org (or a
32
+ -- platform service, which is no org at all) must survive the org going away.
33
+
34
+ create table if not exists foundry_applied_versions (
35
+ id bigserial primary key,
36
+ service text not null,
37
+ ref text not null,
38
+ applied_at timestamptz not null default now(),
39
+ run_id text,
40
+ backup_id text
41
+ );
42
+
43
+ -- `previousAppliedRef`'s read: the most recent rows for one service, newest first.
44
+ create index if not exists foundry_applied_versions_service_idx
45
+ on foundry_applied_versions (service, applied_at desc, id desc);
@@ -0,0 +1,8 @@
1
+ -- @wtfalch/foundry: the declared platform's optional public server IPv4.
2
+ -- Coolify's localhost server reports `host.docker.internal` as its ip, which
3
+ -- cannot be a DNS A record, so the operator declares the real address here
4
+ -- instead of hand-setting it in Coolify. Format is validated by
5
+ -- src/platform.ts's `validatePlatform`, not here (same split as `apex`).
6
+ -- Null: use the Coolify server's own ip when it is an IPv4.
7
+
8
+ alter table foundry_declared_platform add column if not exists server_ipv4 text;
package/dist/org.d.ts CHANGED
@@ -209,7 +209,46 @@ export interface HostingConfig {
209
209
  issuerApps: IssuerApp[];
210
210
  /** One entry per app the org runs. */
211
211
  entries: HostingEntry[];
212
+ /**
213
+ * Cloudflare Access rules for hostnames besides the `app` entry's own
214
+ * (#246): `accessEmails` above already gates that one hostname by an
215
+ * email list; this says what any other hostname an org serves (e.g.
216
+ * `share.<domain>`, `storage.<domain>`) gets instead of an operator
217
+ * setting it up by hand in the Cloudflare dashboard, which is how it is
218
+ * done today. Empty or absent: no additional Access apps.
219
+ */
220
+ access?: AccessRule[];
212
221
  }
222
+ /** The three Cloudflare Access rule kinds {@link HostingConfig.access} supports (#246). */
223
+ export type AccessRuleKind = 'gate' | 'bypass' | 'service-token';
224
+ export declare const ACCESS_RULE_KINDS: ReadonlySet<AccessRuleKind>;
225
+ /**
226
+ * One Cloudflare Access rule on a hostname besides the `app` entry's own
227
+ * (#246, `HostingConfig.access`). `hostname` is the full hostname Access
228
+ * gates (e.g. `share.<domain>`), never an entry key.
229
+ *
230
+ * - `gate`: an email allowlist, the same shape `accessEmails` already
231
+ * applies to the app's own hostname.
232
+ * - `bypass`: no login at all -- the hostname is public through Access.
233
+ * - `service-token`: only a request carrying the named Cloudflare Access
234
+ * service token's headers gets through, no human login flow. The token
235
+ * itself is not minted by this rule -- it names a live Cloudflare Access
236
+ * service token that `executor.ts` looks up by name, the same way its
237
+ * `app`-entry gate finds its Access app by name rather than creating a
238
+ * second one.
239
+ */
240
+ export type AccessRule = {
241
+ hostname: string;
242
+ kind: 'gate';
243
+ emails: string[];
244
+ } | {
245
+ hostname: string;
246
+ kind: 'bypass';
247
+ } | {
248
+ hostname: string;
249
+ kind: 'service-token';
250
+ serviceTokenName: string;
251
+ };
213
252
  /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
214
253
  export declare const APP_ENTRY_KEY = "app";
215
254
  /**
package/dist/org.js CHANGED
@@ -2,6 +2,11 @@
2
2
  export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
3
3
  /** {@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). */
4
4
  export const DEFAULT_NO_REPLY_LOCAL_PART = 'no-reply';
5
+ export const ACCESS_RULE_KINDS = new Set([
6
+ 'gate',
7
+ 'bypass',
8
+ 'service-token',
9
+ ]);
5
10
  /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
6
11
  export const APP_ENTRY_KEY = 'app';
7
12
  /** What an entry's ai key may hold: the most the org's own key holds. */
@@ -118,6 +123,45 @@ export function validateHosting(hosting, names) {
118
123
  if (hosting.entries.filter((entry) => entry.database).length > 1) {
119
124
  return 'at most one hosting entry may set database: the one runtime database role cannot serve two apps';
120
125
  }
126
+ const seenAccessHostnames = new Set();
127
+ for (const rule of hosting.access ?? []) {
128
+ const invalidRule = validateAccessRule(rule, hosting.enabled);
129
+ if (invalidRule)
130
+ return invalidRule;
131
+ if (seenAccessHostnames.has(rule.hostname)) {
132
+ return `hosting.access hostname '${rule.hostname}' is listed twice`;
133
+ }
134
+ seenAccessHostnames.add(rule.hostname);
135
+ }
136
+ return null;
137
+ }
138
+ /** A full DNS hostname, at least two labels -- distinct from an entry's single-label `key`. */
139
+ const ACCESS_RULE_HOSTNAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
140
+ /** The rules for one {@link AccessRule}; `null` when it is valid. */
141
+ function validateAccessRule(rule, enabled) {
142
+ const at = `hosting.access '${rule.hostname}'`;
143
+ if (!rule.hostname || !ACCESS_RULE_HOSTNAME.test(rule.hostname)) {
144
+ return `hosting.access entries need a full hostname ('${rule.hostname}' is not one)`;
145
+ }
146
+ if (!enabled) {
147
+ return `${at} requires hosting.enabled`;
148
+ }
149
+ if (!ACCESS_RULE_KINDS.has(rule.kind)) {
150
+ return `${at} has an unknown kind '${rule.kind}'`;
151
+ }
152
+ if (rule.kind === 'gate') {
153
+ if (rule.emails.length === 0) {
154
+ return `${at} kind 'gate' needs at least one email`;
155
+ }
156
+ for (const email of rule.emails) {
157
+ if (!EMAIL.test(email)) {
158
+ return `${at} has an invalid email '${email}'`;
159
+ }
160
+ }
161
+ }
162
+ if (rule.kind === 'service-token' && !rule.serviceTokenName) {
163
+ return `${at} kind 'service-token' needs a non-empty serviceTokenName`;
164
+ }
121
165
  return null;
122
166
  }
123
167
  /**
@@ -164,6 +208,7 @@ function validateEntryHostnames(hosting, names) {
164
208
  return null;
165
209
  }
166
210
  const ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
211
+ const EMAIL = /^[^@\s]+@[^@\s]+$/;
167
212
  const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
168
213
  // The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
169
214
  const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
@@ -207,7 +252,7 @@ function validateHostingEntry(entry, enabled) {
207
252
  return `${at} accessEmails is only available on the '${APP_ENTRY_KEY}' entry for now`;
208
253
  }
209
254
  for (const email of entry.accessEmails) {
210
- if (!/^[^@\s]+@[^@\s]+$/.test(email)) {
255
+ if (!EMAIL.test(email)) {
211
256
  return `${at} accessEmails has an invalid email '${email}'`;
212
257
  }
213
258
  }
@@ -29,7 +29,7 @@ export declare const BOULE_DATABASE_SCOPE_KEY = "coolify_boule_database";
29
29
  */
30
30
  export declare function bouleHostnameFor(org: DeclaredOrg): string;
31
31
  /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
32
- export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR"];
32
+ export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "ARCHON_URL"];
33
33
  /**
34
34
  * The env vars an org's Boule app should carry, and every input still missing
35
35
  * to build them. Callers that write must refuse on a non-empty `missing`: a
@@ -41,11 +41,17 @@ export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER",
41
41
  * id, never an email), present once `zitadel:owner` has recorded it. It is not
42
42
  * `missing` while absent: Boule runs without an operator until then.
43
43
  *
44
+ * `archonUrl` (#374) is the Archon that bills this org -- `https://` plus the
45
+ * declared platform's `archon.hostname` (`plan-apply.ts`'s `orgArchonUrl`),
46
+ * runtime only, read by Boule's Prices card (boule#228). Not `missing` while
47
+ * absent: Boule has no platform wired in every test and every org-only
48
+ * caller, same as `issuer` before #177.
49
+ *
44
50
  * Not here, and written by the executor instead: the database URLs
45
51
  * (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
46
52
  * (generated once, never rotated).
47
53
  */
48
- export declare function desiredBouleEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, issuer: string | null): {
54
+ export declare function desiredBouleEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, issuer: string | null, archonUrl: string | null): {
49
55
  env: Record<string, string>;
50
56
  missing: string[];
51
57
  };
@@ -37,6 +37,7 @@ export const BOULE_MANAGED_ENV_KEYS = [
37
37
  'AUTH_CLIENT_ID',
38
38
  'BOULE_ORG_NAME',
39
39
  'AUTHZ_BOOTSTRAP_OPERATOR',
40
+ 'ARCHON_URL',
40
41
  ];
41
42
  /**
42
43
  * The env vars an org's Boule app should carry, and every input still missing
@@ -49,11 +50,17 @@ export const BOULE_MANAGED_ENV_KEYS = [
49
50
  * id, never an email), present once `zitadel:owner` has recorded it. It is not
50
51
  * `missing` while absent: Boule runs without an operator until then.
51
52
  *
53
+ * `archonUrl` (#374) is the Archon that bills this org -- `https://` plus the
54
+ * declared platform's `archon.hostname` (`plan-apply.ts`'s `orgArchonUrl`),
55
+ * runtime only, read by Boule's Prices card (boule#228). Not `missing` while
56
+ * absent: Boule has no platform wired in every test and every org-only
57
+ * caller, same as `issuer` before #177.
58
+ *
52
59
  * Not here, and written by the executor instead: the database URLs
53
60
  * (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
54
61
  * (generated once, never rotated).
55
62
  */
56
- export function desiredBouleEnv(org, recorded, issuer) {
63
+ export function desiredBouleEnv(org, recorded, issuer, archonUrl) {
57
64
  const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
58
65
  const clientId = recorded[BOULE_CLIENT_ID_SCOPE_KEY];
59
66
  const missing = [];
@@ -73,6 +80,8 @@ export function desiredBouleEnv(org, recorded, issuer) {
73
80
  env.AUTH_ORGANIZATION_ID = zitadelOrgId;
74
81
  if (clientId)
75
82
  env.AUTH_CLIENT_ID = clientId;
83
+ if (archonUrl)
84
+ env.ARCHON_URL = archonUrl;
76
85
  const owner = org.owners[0];
77
86
  const ownerUserId = owner
78
87
  ? recorded[`zitadel:owner:${org.id}:${owner.email.toLowerCase()}`]
@@ -40,6 +40,8 @@ export interface PlanOrgOptions {
40
40
  otherSlugs?: readonly string[];
41
41
  /** F6a, F6b: the clock `planEntryAiKeys` and `planFilesKeys` read for the renewal window. Default `Date.now`. */
42
42
  now?: () => number;
43
+ /** #374: the Archon that bills this org (`plan-apply.ts`'s `orgArchonUrl`), written into Boule's env as `ARCHON_URL`. `null` with no platform wired. */
44
+ archonUrl?: string | null;
43
45
  }
44
46
  export interface PlanDetail {
45
47
  orgId: OrgId;
package/dist/plan/diff.js CHANGED
@@ -242,7 +242,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
242
242
  const bouleClient = planBouleClient(org, byKey, claimed);
243
243
  if (bouleClient)
244
244
  plannedSteps.push(bouleClient);
245
- const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? []);
245
+ const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null);
246
246
  if (boule.step)
247
247
  plannedSteps.push(boule.step);
248
248
  // Boule's own broker entry, after its app: the entry needs the AUTH_APP_KEY
@@ -1498,7 +1498,7 @@ function planBouleClient(org, byKey, claimed) {
1498
1498
  * address (the one Agora signs in at); without it the Boule is blocked, not
1499
1499
  * planned, so every other step still applies.
1500
1500
  */
1501
- function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs) {
1501
+ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl) {
1502
1502
  const key = resourceKey('coolify', 'boule', org.id);
1503
1503
  const live = byKey.get(key);
1504
1504
  if (live)
@@ -1532,7 +1532,7 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs) {
1532
1532
  system: 'coolify',
1533
1533
  kind: 'boule',
1534
1534
  key: org.id,
1535
- desired: { issuer },
1535
+ desired: { issuer, archonUrl },
1536
1536
  };
1537
1537
  if (!live) {
1538
1538
  return {
@@ -1544,7 +1544,7 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs) {
1544
1544
  };
1545
1545
  }
1546
1546
  const liveEnv = (live.config.env ?? {});
1547
- const { env } = desiredBouleEnv(org, recordedIds, issuer);
1547
+ const { env } = desiredBouleEnv(org, recordedIds, issuer, archonUrl);
1548
1548
  const drift = bouleEnvDrift(env, liveEnv);
1549
1549
  if (drift.length === 0)
1550
1550
  return { step: null, blocked: null };
@@ -2320,13 +2320,21 @@ function planCustomers(org, live, claimed, recordedIds) {
2320
2320
  * each entry's `accessEmails` through. Idempotent on the recorded Access app id,
2321
2321
  * like `planOwners`: Cloudflare's live listing has no Access apps. A later
2322
2322
  * change to the email list is not reconciled yet.
2323
+ *
2324
+ * #246: widened to also plan one step per `hosting.access` rule -- a
2325
+ * `gate`, `bypass` or `service-token` Access policy on a hostname besides
2326
+ * the app's own (`share.<domain>`, `storage.<domain>`, ...), which the
2327
+ * `accessEmails` gate above has no way to express: it is email-only, and
2328
+ * only for the `app` entry. With no rules declared this plans exactly what
2329
+ * it planned before #246 -- no regression for an org that never set
2330
+ * `hosting.access`.
2323
2331
  */
2324
2332
  function planAccess(org, recordedIds) {
2325
2333
  if (!org.hosting.enabled)
2326
2334
  return [];
2327
2335
  // Only the `app` entry has a public name, and so only it can sit behind
2328
2336
  // Access; per-entry hostnames are issue 309.
2329
- return org.hosting.entries
2337
+ const appGate = org.hosting.entries
2330
2338
  .filter((entry) => entry.key === APP_ENTRY_KEY && entry.accessEmails.length > 0)
2331
2339
  .filter((entry) => !recordedIds[resourceKey('cloudflare', 'access', appStepKey(org, entry.key))])
2332
2340
  .map((entry) => ({
@@ -2342,6 +2350,33 @@ function planAccess(org, recordedIds) {
2342
2350
  migrates: false,
2343
2351
  },
2344
2352
  }));
2353
+ const ruleSteps = (org.hosting.access ?? [])
2354
+ .filter((rule) => !recordedIds[resourceKey('cloudflare', 'access', appStepKey(org, rule.hostname))])
2355
+ .map((rule) => ({
2356
+ action: {
2357
+ system: 'cloudflare',
2358
+ kind: 'access',
2359
+ key: appStepKey(org, rule.hostname),
2360
+ desired: {},
2361
+ },
2362
+ step: {
2363
+ description: accessRuleDescription(org, rule),
2364
+ destroys: false,
2365
+ migrates: false,
2366
+ },
2367
+ }));
2368
+ return [...appGate, ...ruleSteps];
2369
+ }
2370
+ /** The plan description for one `hosting.access` rule, by kind (#246). */
2371
+ function accessRuleDescription(org, rule) {
2372
+ switch (rule.kind) {
2373
+ case 'gate':
2374
+ return `gate ${org.slug}'s ${rule.hostname} behind Cloudflare Access`;
2375
+ case 'bypass':
2376
+ return `let ${rule.hostname} (${org.slug}) bypass Cloudflare Access`;
2377
+ case 'service-token':
2378
+ return `let only the '${rule.serviceTokenName}' service token through Cloudflare Access on ${rule.hostname} (${org.slug})`;
2379
+ }
2345
2380
  }
2346
2381
  /**
2347
2382
  * https://github.com/wtfalch/foundry/issues/43: one Cloudflare rate-limiting
@@ -420,6 +420,15 @@ export interface CloudflarePort {
420
420
  }): Promise<{
421
421
  id: string;
422
422
  }>;
423
+ /**
424
+ * `cloudflareAccess` resolves a `kind: 'service-token'` rule's named
425
+ * token to its id (#246) -- the token itself is a live Cloudflare
426
+ * resource this package never mints; `undefined` when no token by that
427
+ * name exists yet.
428
+ */
429
+ findServiceTokenByName(name: string): Promise<{
430
+ id: string;
431
+ } | undefined>;
423
432
  deleteAccessApp(appId: string): Promise<void>;
424
433
  /**
425
434
  * `FoundryPlanApply.destroy`'s teardown of any DNS record foundry created
@@ -1394,15 +1403,30 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1394
1403
  resourceId?: string;
1395
1404
  } | undefined>;
1396
1405
  /**
1397
- * `plan/diff.ts`'s `planAccess` (#84): puts the org's app hostname behind a
1398
- * Cloudflare Access app that lets only the `app` entry's `accessEmails` through.
1399
- * foundry names its Access app `foundry:<orgId>` and reuses only an app with
1400
- * that name, so a re-run after a crash does not duplicate it. It refuses,
1401
- * rather than adopt, when another Access app already holds the hostname:
1402
- * destroy would later delete it. It also refuses when its own app carries
1406
+ * `plan/diff.ts`'s `planAccess` (#84, widened by #246): puts a hostname
1407
+ * behind a Cloudflare Access app. `action.key` tells the `app` entry's own
1408
+ * gate (`accessEmails`, email-only) apart from a `hosting.access` rule on
1409
+ * any other hostname -- `appStepKey` builds both, keyed by the entry's key
1410
+ * or by the rule's hostname respectively, and the two never collide
1411
+ * because an entry key never holds a `.` while a rule's hostname always
1412
+ * does (`org.ts`'s `validateAccessRule`). The app-entry gate keeps the
1413
+ * Access app name `foundry:<orgId>` it has always used, so a re-run
1414
+ * against an org applied before #246 still finds the app it already
1415
+ * created; a rule's own app is named `foundry:<orgId>:<hostname>` instead,
1416
+ * since an org may declare more than one. Either way foundry reuses only
1417
+ * an app with its own name (so a re-run after a crash does not duplicate
1418
+ * it), refuses to adopt a hostname another Access app already holds
1419
+ * (destroy would later delete it), and refuses when its own app carries
1403
1420
  * any policy but foundry's, since that policy could let others in.
1404
1421
  */
1405
1422
  private cloudflareAccess;
1423
+ /** The Cloudflare Access policy body for one `hosting.access` rule, by kind (#246). */
1424
+ private accessPolicyFor;
1425
+ /**
1426
+ * The create-or-adopt-refusal logic `cloudflareAccess` shares between the
1427
+ * `app` entry's own gate and every `hosting.access` rule (#84, #246).
1428
+ */
1429
+ private cloudflareAccessApp;
1406
1430
  /**
1407
1431
  * `plan/diff.ts`'s `planRateLimit` (#43): one Cloudflare rate limiting
1408
1432
  * rule, scoped to `SIGN_IN_PATH_EXPRESSION`, on the org's own Cloudflare
@@ -143,6 +143,19 @@ function mailOrgFor(org, usesDomain, mode) {
143
143
  const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
144
144
  /** The one Access policy foundry puts on its own Access app (#84). */
145
145
  export const ACCESS_POLICY_NAME = 'foundry: allowed emails';
146
+ /**
147
+ * The Access policy name for each `hosting.access` rule kind (#246),
148
+ * `cloudflareAccess`'s way of telling its own policy apart from a foreign
149
+ * one on the same app -- the same role {@link ACCESS_POLICY_NAME} plays for
150
+ * the `app` entry's own email gate, which keeps that exact name so a
151
+ * re-run against an org applied before #246 still recognises its own
152
+ * policy.
153
+ */
154
+ const ACCESS_RULE_POLICY_NAMES = {
155
+ gate: ACCESS_POLICY_NAME,
156
+ bypass: 'foundry: bypass',
157
+ 'service-token': 'foundry: service token',
158
+ };
146
159
  /**
147
160
  * The sign-in broker's own route mount (`@wtfalch/auth`'s `NextAuth`,
148
161
  * `dist/next.d.ts`'s `handlers` doc comment: "`export const { GET, POST } =
@@ -635,7 +648,7 @@ export class FoundryStepExecutor {
635
648
  case 'cloudflare:zone':
636
649
  return this.cloudflareZone(action, org);
637
650
  case 'cloudflare:access':
638
- return this.cloudflareAccess(org);
651
+ return this.cloudflareAccess(action, org);
639
652
  case 'cloudflare:ratelimit':
640
653
  return this.cloudflareRateLimit(org);
641
654
  case 'files:organisation':
@@ -677,40 +690,91 @@ export class FoundryStepExecutor {
677
690
  }
678
691
  }
679
692
  /**
680
- * `plan/diff.ts`'s `planAccess` (#84): puts the org's app hostname behind a
681
- * Cloudflare Access app that lets only the `app` entry's `accessEmails` through.
682
- * foundry names its Access app `foundry:<orgId>` and reuses only an app with
683
- * that name, so a re-run after a crash does not duplicate it. It refuses,
684
- * rather than adopt, when another Access app already holds the hostname:
685
- * destroy would later delete it. It also refuses when its own app carries
693
+ * `plan/diff.ts`'s `planAccess` (#84, widened by #246): puts a hostname
694
+ * behind a Cloudflare Access app. `action.key` tells the `app` entry's own
695
+ * gate (`accessEmails`, email-only) apart from a `hosting.access` rule on
696
+ * any other hostname -- `appStepKey` builds both, keyed by the entry's key
697
+ * or by the rule's hostname respectively, and the two never collide
698
+ * because an entry key never holds a `.` while a rule's hostname always
699
+ * does (`org.ts`'s `validateAccessRule`). The app-entry gate keeps the
700
+ * Access app name `foundry:<orgId>` it has always used, so a re-run
701
+ * against an org applied before #246 still finds the app it already
702
+ * created; a rule's own app is named `foundry:<orgId>:<hostname>` instead,
703
+ * since an org may declare more than one. Either way foundry reuses only
704
+ * an app with its own name (so a re-run after a crash does not duplicate
705
+ * it), refuses to adopt a hostname another Access app already holds
706
+ * (destroy would later delete it), and refuses when its own app carries
686
707
  * any policy but foundry's, since that policy could let others in.
687
708
  */
688
- async cloudflareAccess(org) {
709
+ async cloudflareAccess(action, org) {
689
710
  // Only the `app` entry has a public name (`planAccess`); per-entry
690
711
  // hostnames are issue 309.
691
712
  const entry = appEntry(org.hosting);
692
713
  if (!entry)
693
714
  throw new Error(`cloudflare:access for ${org.slug}: no '${APP_ENTRY_KEY}' hosting entry`);
694
- const domain = appHostname(org);
695
- const name = `foundry:${org.id}`;
715
+ if (action.key === appStepKey(org, entry.key)) {
716
+ return this.cloudflareAccessApp(org, {
717
+ appName: `foundry:${org.id}`,
718
+ domain: appHostname(org),
719
+ policyName: ACCESS_POLICY_NAME,
720
+ policy: {
721
+ decision: 'allow',
722
+ include: entry.accessEmails.map((email) => ({ email: { email } })),
723
+ },
724
+ });
725
+ }
726
+ const rule = (org.hosting.access ?? []).find((r) => appStepKey(org, r.hostname) === action.key);
727
+ if (!rule) {
728
+ throw new Error(`cloudflare:access for ${org.slug}: no hosting.access rule matches step key '${action.key}'`);
729
+ }
730
+ return this.cloudflareAccessApp(org, {
731
+ appName: `foundry:${org.id}:${rule.hostname}`,
732
+ domain: rule.hostname,
733
+ policyName: ACCESS_RULE_POLICY_NAMES[rule.kind],
734
+ policy: await this.accessPolicyFor(org, rule),
735
+ });
736
+ }
737
+ /** The Cloudflare Access policy body for one `hosting.access` rule, by kind (#246). */
738
+ async accessPolicyFor(org, rule) {
739
+ switch (rule.kind) {
740
+ case 'gate':
741
+ return {
742
+ decision: 'allow',
743
+ include: rule.emails.map((email) => ({ email: { email } })),
744
+ };
745
+ case 'bypass':
746
+ // A bypass policy still needs an `include` rule to match against;
747
+ // `everyone` is Cloudflare's own shape for "match every visitor".
748
+ return { decision: 'bypass', include: [{ everyone: {} }] };
749
+ case 'service-token': {
750
+ const token = await this.ports.cloudflare.findServiceTokenByName(rule.serviceTokenName);
751
+ if (!token) {
752
+ throw new Error(`cloudflare:access for ${org.slug}: no Cloudflare Access service token named '${rule.serviceTokenName}'`);
753
+ }
754
+ return { decision: 'non_identity', include: [{ service_token: { token_id: token.id } }] };
755
+ }
756
+ }
757
+ }
758
+ /**
759
+ * The create-or-adopt-refusal logic `cloudflareAccess` shares between the
760
+ * `app` entry's own gate and every `hosting.access` rule (#84, #246).
761
+ */
762
+ async cloudflareAccessApp(org, opts) {
763
+ const { appName, domain, policyName, policy } = opts;
696
764
  const cf = this.ports.cloudflare;
697
765
  const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
698
- const foreign = onDomain.find((a) => a.name !== name);
766
+ const foreign = onDomain.find((a) => a.name !== appName);
699
767
  if (foreign) {
700
768
  throw new Error(`cloudflare:access for ${org.slug}: Access app '${foreign.name}' already holds ${domain}; foundry does not adopt it`);
701
769
  }
702
- const app = onDomain[0] ?? (await cf.createAccessApp({ name, domain, type: 'self_hosted' }));
770
+ const app = onDomain[0] ?? (await cf.createAccessApp({ name: appName, domain, type: 'self_hosted' }));
703
771
  const policies = await cf.listAccessPolicies(app.id);
704
- const other = policies.find((p) => p.name !== ACCESS_POLICY_NAME);
772
+ const other = policies.find((p) => p.name !== policyName);
705
773
  if (other) {
706
774
  throw new Error(`cloudflare:access for ${org.slug}: Access app ${app.id} has policy '${other.name}' that foundry did not create`);
707
775
  }
708
776
  if (policies.length === 0) {
709
- await cf.createAccessPolicy(app.id, {
710
- name: ACCESS_POLICY_NAME,
711
- decision: 'allow',
712
- include: entry.accessEmails.map((email) => ({ email: { email } })),
713
- });
777
+ await cf.createAccessPolicy(app.id, { name: policyName, ...policy });
714
778
  }
715
779
  return { resourceId: app.id };
716
780
  }
@@ -3055,10 +3119,11 @@ export class FoundryStepExecutor {
3055
3119
  * (`bouleBrokerEntry`).
3056
3120
  */
3057
3121
  async coolifyBoule(action, org, context) {
3058
- // The issuer the plan read, carried in the step: the env written is the
3059
- // env the plan showed.
3122
+ // The issuer and Archon URL the plan read, carried in the step: the env
3123
+ // written is the env the plan showed.
3060
3124
  const issuer = action.desired.issuer ?? null;
3061
- const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer);
3125
+ const archonUrl = action.desired.archonUrl ?? null;
3126
+ const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl);
3062
3127
  if (missing.length > 0) {
3063
3128
  throw new Error(`Boule for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
3064
3129
  }