@wtfalch/foundry 0.22.5 → 0.24.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.
@@ -1,4 +1,4 @@
1
- import { validateOwners } from '../org.js';
1
+ import { DEFAULT_GATE_LIMIT, validateOwners } from '../org.js';
2
2
  import { PLATFORM_SERVICE_KEYS, defaultPlatformHostname } from '../platform.js';
3
3
  import { VERSIONS } from './versions.js';
4
4
  /**
@@ -107,6 +107,7 @@ export function declaredCompanyOrg(company, owner) {
107
107
  tunnel: false,
108
108
  issuerApps: [],
109
109
  entries: [],
110
+ gateLimit: DEFAULT_GATE_LIMIT,
110
111
  },
111
112
  sso: { enabled: false },
112
113
  guests: { enabled: false },
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, } from './org.js';
2
- export { APP_ENTRY_KEY, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, appEntry, ownerMailboxes, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateSite, } from './org.js';
1
+ export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EntryGate, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, } from './org.js';
2
+ export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, appEntry, ownerMailboxes, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateSite, } from './org.js';
3
3
  export type { DeclaredPlatform, PlatformService, PlatformServiceKey } from './platform.js';
4
4
  export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
5
5
  import { type PinnedVersions } from './bootstrap/versions.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { APP_ENTRY_KEY, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, appEntry, ownerMailboxes, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateSite, } from './org.js';
1
+ export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, appEntry, ownerMailboxes, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateSite, } from './org.js';
2
2
  export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
3
3
  // archon#273: the pinned platform service versions the planner deploys, so a
4
4
  // host can show declared against running. Readonly: it is the planner's own
@@ -0,0 +1,20 @@
1
+ -- @wtfalch/foundry: the Coolify panel's address, added to the declared
2
+ -- platform so the platform's hostnames include it. Mirrors `src/store/schema.ts`'s
3
+ -- `foundryDeclaredPlatform` table additions. Foundry never deploys the panel:
4
+ -- `coolify_enabled` stays false and `coolify_git_ref` empty. Backfills
5
+ -- `coolify_hostname` the same way `0046_platform_hc.sql` backfilled
6
+ -- `hc_hostname`: the column must not be empty (`src/platform.ts`'s
7
+ -- `validatePlatform`), and the table already carries its one live row by the
8
+ -- time this runs.
9
+
10
+ alter table foundry_declared_platform
11
+ add column if not exists coolify_enabled boolean not null default false,
12
+ add column if not exists coolify_hostname text,
13
+ add column if not exists coolify_git_ref text not null default '';
14
+
15
+ update foundry_declared_platform
16
+ set coolify_hostname = 'coolify.' || apex
17
+ where coolify_hostname is null;
18
+
19
+ alter table foundry_declared_platform
20
+ alter column coolify_hostname set not null;
@@ -0,0 +1,7 @@
1
+ -- @wtfalch/foundry: the per-company limit on Access gates (#603), persisted.
2
+ -- Mirrors `src/store/schema.ts`'s `hostingGateLimit`. Additive: an older
3
+ -- reader ignores the column, and a row written before it reads the default, 3
4
+ -- (`DEFAULT_GATE_LIMIT` in `src/org.ts`).
5
+
6
+ alter table foundry_declared_orgs
7
+ add column if not exists hosting_gate_limit integer not null default 3;
package/dist/org.d.ts CHANGED
@@ -242,25 +242,26 @@ export interface HostingConfig {
242
242
  /** One entry per app the org runs. */
243
243
  entries: HostingEntry[];
244
244
  /**
245
- * Cloudflare Access rules for hostnames besides the `app` entry's own
246
- * (#246): `accessEmails` above already gates that one hostname by an
247
- * email list; this says what any other hostname an org serves (e.g.
248
- * `share.<domain>`, `storage.<domain>`) gets instead of an operator
249
- * setting it up by hand in the Cloudflare dashboard, which is how it is
250
- * done today. Empty or absent: no additional Access apps.
245
+ * How a hostname the org serves gets past Cloudflare Access (#246): a name
246
+ * that is public (`bypass`) or admits a machine (`service-token`) instead
247
+ * of an operator setting it up by hand in the Cloudflare dashboard. Who
248
+ * passes for people is an entry's `gates`. Empty or absent: no additional
249
+ * Access apps.
251
250
  */
252
251
  access?: AccessRule[];
252
+ /** How many gates the org may declare across all its entries. Only the platform operator sets it. Default: `3`. */
253
+ gateLimit: number;
253
254
  }
254
- /** The three Cloudflare Access rule kinds {@link HostingConfig.access} supports (#246). */
255
- export type AccessRuleKind = 'gate' | 'bypass' | 'service-token';
255
+ /** The two Cloudflare Access rule kinds {@link HostingConfig.access} supports (#246). */
256
+ export type AccessRuleKind = 'bypass' | 'service-token';
256
257
  export declare const ACCESS_RULE_KINDS: ReadonlySet<AccessRuleKind>;
258
+ /** The gates an org may declare when it has no `gateLimit` of its own. */
259
+ export declare const DEFAULT_GATE_LIMIT = 3;
257
260
  /**
258
- * One Cloudflare Access rule on a hostname besides the `app` entry's own
259
- * (#246, `HostingConfig.access`). `hostname` is the full hostname Access
260
- * gates (e.g. `share.<domain>`), never an entry key.
261
+ * One Cloudflare Access rule on a hostname the org serves under its own
262
+ * domain (#246, `HostingConfig.access`). `hostname` is the full hostname
263
+ * Access acts on (e.g. `share.<domain>`), never an entry key.
261
264
  *
262
- * - `gate`: an email allowlist, the same shape `accessEmails` already
263
- * applies to the app's own hostname.
264
265
  * - `bypass`: no login at all -- the hostname is public through Access.
265
266
  * - `service-token`: only a request carrying the named Cloudflare Access
266
267
  * service token's headers gets through, no human login flow. The token
@@ -270,10 +271,6 @@ export declare const ACCESS_RULE_KINDS: ReadonlySet<AccessRuleKind>;
270
271
  * second one.
271
272
  */
272
273
  export type AccessRule = {
273
- hostname: string;
274
- kind: 'gate';
275
- emails: string[];
276
- } | {
277
274
  hostname: string;
278
275
  kind: 'bypass';
279
276
  } | {
@@ -284,9 +281,9 @@ export type AccessRule = {
284
281
  /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
285
282
  export declare const APP_ENTRY_KEY = "app";
286
283
  /**
287
- * One app the org runs. `database` and `accessEmails` are meaningless
288
- * without `HostingConfig.enabled`, so `validateHosting` requires it before
289
- * either is set.
284
+ * One app the org runs. `database` and `gates` are meaningless without
285
+ * `HostingConfig.enabled`, so `validateHosting` requires it before either is
286
+ * set.
290
287
  */
291
288
  export interface HostingEntry {
292
289
  /** A DNS label, unique per org. It goes into step ids and scope keys, so it never holds `:` or `.`. */
@@ -317,10 +314,10 @@ export interface HostingEntry {
317
314
  /** Deploy on every push to `branch`. Default: `true`. */
318
315
  autoDeploy: boolean;
319
316
  /**
320
- * Emails Cloudflare Access lets through to the app's hostname (#84). Empty
321
- * means no Access gate. Only the entry keyed {@link APP_ENTRY_KEY} may set it.
317
+ * Cloudflare Access gates on this entry's own `hostnames`. Empty means no
318
+ * gate. Who passes is not declared here.
322
319
  */
323
- accessEmails: string[];
320
+ gates: EntryGate[];
324
321
  /**
325
322
  * The app's own env var names and what they hold (#243). Each bespoke app
326
323
  * names the values the plan mints (Files, AI, mail) differently (lokessmie
@@ -341,6 +338,13 @@ export interface HostingEntry {
341
338
  */
342
339
  worker?: WorkerConfig;
343
340
  }
341
+ /** One Cloudflare Access gate over some of an entry's `hostnames`. */
342
+ export interface EntryGate {
343
+ /** A DNS label, unique in the org. It names the plan step and the edge gate. */
344
+ key: string;
345
+ /** Values from the same entry's `hostnames`: `'@'`, a label or `'*'`. The whole domain is `['@', '*']`. */
346
+ hostnames: string[];
347
+ }
344
348
  /**
345
349
  * One Cloudflare rate limiting binding on a Worker (#318) -- Cloudflare's own
346
350
  * "simple" shape: a fixed number of requests per period, where `period` is
package/dist/org.js CHANGED
@@ -3,11 +3,9 @@ import { floatingTagBlocked } from './bootstrap/versions.js';
3
3
  export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
4
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). */
5
5
  export const DEFAULT_NO_REPLY_LOCAL_PART = 'no-reply';
6
- export const ACCESS_RULE_KINDS = new Set([
7
- 'gate',
8
- 'bypass',
9
- 'service-token',
10
- ]);
6
+ export const ACCESS_RULE_KINDS = new Set(['bypass', 'service-token']);
7
+ /** The gates an org may declare when it has no `gateLimit` of its own. */
8
+ export const DEFAULT_GATE_LIMIT = 3;
11
9
  /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
12
10
  export const APP_ENTRY_KEY = 'app';
13
11
  /** What an entry's ai key may hold: the most the org's own key holds. */
@@ -162,9 +160,12 @@ export function validateHosting(hosting, names) {
162
160
  if (hosting.entries.filter((entry) => entry.database).length > 1) {
163
161
  return 'at most one hosting entry may set database: the one runtime database role cannot serve two apps';
164
162
  }
163
+ const invalidGates = validateGates(hosting, names?.domain, names?.locked);
164
+ if (invalidGates)
165
+ return invalidGates;
165
166
  const seenAccessHostnames = new Set();
166
167
  for (const rule of hosting.access ?? []) {
167
- const invalidRule = validateAccessRule(rule, hosting.enabled);
168
+ const invalidRule = validateAccessRule(rule, hosting, names);
168
169
  if (invalidRule)
169
170
  return invalidRule;
170
171
  if (seenAccessHostnames.has(rule.hostname)) {
@@ -176,30 +177,85 @@ export function validateHosting(hosting, names) {
176
177
  }
177
178
  /** A full DNS hostname, at least two labels -- distinct from an entry's single-label `key`. */
178
179
  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])?)+$/;
179
- /** The rules for one {@link AccessRule}; `null` when it is valid. */
180
- function validateAccessRule(rule, enabled) {
180
+ /**
181
+ * The rules for one {@link AccessRule}; `null` when it is valid. The hostname
182
+ * must be a name the org serves under its own domain and never a locked one;
183
+ * without `names` only the checks that need no domain run.
184
+ */
185
+ function validateAccessRule(rule, hosting, names) {
181
186
  const at = `hosting.access '${rule.hostname}'`;
182
187
  if (!rule.hostname || !ACCESS_RULE_HOSTNAME.test(rule.hostname)) {
183
188
  return `hosting.access entries need a full hostname ('${rule.hostname}' is not one)`;
184
189
  }
185
- if (!enabled) {
190
+ if (!hosting.enabled) {
186
191
  return `${at} requires hosting.enabled`;
187
192
  }
188
193
  if (!ACCESS_RULE_KINDS.has(rule.kind)) {
189
194
  return `${at} has an unknown kind '${rule.kind}'`;
190
195
  }
191
- if (rule.kind === 'gate') {
192
- if (rule.emails.length === 0) {
193
- return `${at} kind 'gate' needs at least one email`;
196
+ if (rule.kind === 'service-token' && !rule.serviceTokenName) {
197
+ return `${at} kind 'service-token' needs a non-empty serviceTokenName`;
198
+ }
199
+ if (names) {
200
+ if (names.locked.has(rule.hostname)) {
201
+ return `${at} is a name foundry owns`;
194
202
  }
195
- for (const email of rule.emails) {
196
- if (!EMAIL.test(email)) {
197
- return `${at} has an invalid email '${email}'`;
203
+ const { domain } = names;
204
+ const served = hosting.usesDomain &&
205
+ domain !== null &&
206
+ hosting.entries.some((entry) => entry.hostnames.some((name) => name === '*'
207
+ ? rule.hostname.endsWith(`.${domain}`)
208
+ : rule.hostname === (name === '@' ? domain : `${name}.${domain}`)));
209
+ if (!served) {
210
+ return `${at} is not a name the org serves under its own domain`;
211
+ }
212
+ }
213
+ return null;
214
+ }
215
+ /**
216
+ * The rules for every entry's `gates` and for `gateLimit`: the limit is a
217
+ * positive whole number; gates need `enabled`, `usesDomain` and (when the
218
+ * caller knows it) a domain; each key is a DNS label unique in the org; each
219
+ * gate names at least one of its own entry's hostnames, none in two gates;
220
+ * and the org holds at most `gateLimit` of them.
221
+ */
222
+ function validateGates(hosting, domain, locked) {
223
+ if (!Number.isInteger(hosting.gateLimit) || hosting.gateLimit < 1) {
224
+ return 'hosting.gateLimit must be a positive whole number';
225
+ }
226
+ const seenKeys = new Set();
227
+ for (const entry of hosting.entries) {
228
+ const taken = new Set();
229
+ for (const gate of entry.gates) {
230
+ const at = `hosting.entries '${entry.key}' gate '${gate.key}'`;
231
+ if (!hosting.enabled || !hosting.usesDomain || domain === null) {
232
+ return `${at} needs hosting.enabled, hosting.usesDomain and an org domain`;
233
+ }
234
+ if (!ENTRY_KEY.test(gate.key)) {
235
+ return `${at} key must be a DNS label (lowercase letters, digits and hyphens, 1-63 characters)`;
236
+ }
237
+ if (seenKeys.has(gate.key))
238
+ return `${at} key is used by two gates`;
239
+ seenKeys.add(gate.key);
240
+ if (gate.hostnames.length === 0)
241
+ return `${at} needs at least one hostname`;
242
+ for (const name of gate.hostnames) {
243
+ if (!entry.hostnames.includes(name)) {
244
+ return `${at} hostname '${name}' is not one of the entry's hostnames`;
245
+ }
246
+ if (taken.has(name))
247
+ return `${at} hostname '${name}' is held by two gates or listed twice`;
248
+ taken.add(name);
249
+ if (domain && name !== '*') {
250
+ const full = name === '@' ? domain : `${name}.${domain}`;
251
+ if (locked?.has(full))
252
+ return `${at} hostname '${name}' is a name foundry owns: ${full}`;
253
+ }
198
254
  }
199
255
  }
200
256
  }
201
- if (rule.kind === 'service-token' && !rule.serviceTokenName) {
202
- return `${at} kind 'service-token' needs a non-empty serviceTokenName`;
257
+ if (seenKeys.size > hosting.gateLimit) {
258
+ return `hosting declares ${seenKeys.size} gates, over its limit of ${hosting.gateLimit}`;
203
259
  }
204
260
  return null;
205
261
  }
@@ -247,7 +303,6 @@ function validateEntryHostnames(hosting, names) {
247
303
  return null;
248
304
  }
249
305
  const ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
250
- const EMAIL = /^[^@\s]+@[^@\s]+$/;
251
306
  const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
252
307
  // The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
253
308
  const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
@@ -284,17 +339,6 @@ function validateHostingEntry(entry, enabled) {
284
339
  if (entry.database && !enabled) {
285
340
  return `${at} database requires hosting.enabled`;
286
341
  }
287
- if (entry.accessEmails.length > 0 && !enabled) {
288
- return `${at} accessEmails requires hosting.enabled`;
289
- }
290
- if (entry.accessEmails.length > 0 && entry.key !== APP_ENTRY_KEY) {
291
- return `${at} accessEmails is only available on the '${APP_ENTRY_KEY}' entry for now`;
292
- }
293
- for (const email of entry.accessEmails) {
294
- if (!EMAIL.test(email)) {
295
- return `${at} accessEmails has an invalid email '${email}'`;
296
- }
297
- }
298
342
  for (const [name, value] of Object.entries(entry.env)) {
299
343
  if (!name) {
300
344
  return `${at} env needs a non-empty env var name`;
package/dist/plan/diff.js CHANGED
@@ -7,7 +7,9 @@ import { chatInternalNames } from './chat-matrix.js';
7
7
  import { planEntryAiKeys } from './entry-ai-key.js';
8
8
  import { planEdgeReservedLabels, planEntryEdgeKeys } from './entry-edge-key.js';
9
9
  import { planFilesKeys } from './entry-files-key.js';
10
+ import { hasWildcardGate, planGates } from './gates.js';
10
11
  import { appHostname, entryHostnames, tunnelDnsHostnames, tunnelDomainList } from './hostnames.js';
12
+ import { lockedHostnames } from './locked-hostnames.js';
11
13
  import { LOGIN_BINDING_SCOPE_KEY, companyLoginTunnelHost, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
12
14
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
13
15
  import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, } from './platform-diff.js';
@@ -286,7 +288,15 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
286
288
  // An Access gate that cannot be planned must not leave the hostname it
287
289
  // guards routed: the tunnel (and its DNS) that exposes it waits too.
288
290
  let accessGateBlocked = false;
289
- for (const step of planAccess(org, recordedIds)) {
291
+ const access = planAccess(org, recordedIds, platformReadiness.accessProviderId ?? null, options.platformHostnames ?? []);
292
+ if (access.blocked) {
293
+ accessGateBlocked = true;
294
+ dnsBlocked.push({
295
+ description: `${org.slug}'s Access gate is blocked, not planned: ${access.blocked}`,
296
+ key: 'cloudflare:gate',
297
+ });
298
+ }
299
+ for (const step of access.steps) {
290
300
  const reason = edgeNeeded(platformReadiness, ['access']);
291
301
  if (reason) {
292
302
  accessGateBlocked = true;
@@ -503,6 +513,14 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
503
513
  unmanaged.push({ description: zone.blocked, key: 'cloudflare:zone' });
504
514
  unmanaged.push(...zoneBlocked);
505
515
  unmanaged.push(...dnsBlocked);
516
+ // A legacy email-list Access app is no longer planned or removed: an operator deletes it by hand.
517
+ const legacyAccessKey = resourceKey('cloudflare', 'access', appStepKey(org, APP_ENTRY_KEY));
518
+ if (recordedIds[legacyAccessKey]) {
519
+ unmanaged.push({
520
+ description: `${org.slug}'s old Cloudflare Access app (${recordedIds[legacyAccessKey]}, recorded as ${legacyAccessKey}) is no longer managed; remove it in Cloudflare by hand, then remove the recorded key`,
521
+ key: 'cloudflare:access',
522
+ });
523
+ }
506
524
  // #243: an appEnv source nothing will record is reported, not silently skipped.
507
525
  unmanaged.push(...app.blocked);
508
526
  unmanaged.push(...brokerEntries.blocked);
@@ -2912,41 +2930,33 @@ function planCustomers(org, live, claimed, recordedIds) {
2912
2930
  };
2913
2931
  }
2914
2932
  /**
2915
- * #84: a Cloudflare Access gate on the org's app hostname, letting only
2916
- * each entry's `accessEmails` through. Idempotent on the recorded Access app id,
2917
- * like `planOwners`: Cloudflare's live listing has no Access apps. A later
2918
- * change to the email list is not reconciled yet.
2919
- *
2920
- * #246: widened to also plan one step per `hosting.access` rule -- a
2921
- * `gate`, `bypass` or `service-token` Access policy on a hostname besides
2922
- * the app's own (`share.<domain>`, `storage.<domain>`, ...), which the
2923
- * `accessEmails` gate above has no way to express: it is email-only, and
2924
- * only for the `app` entry. With no rules declared this plans exactly what
2925
- * it planned before #246 -- no regression for an org that never set
2926
- * `hosting.access`.
2933
+ * #246: one step per `hosting.access` rule -- a `bypass` or `service-token`
2934
+ * Access policy on a hostname the org serves (`share.<domain>`,
2935
+ * `storage.<domain>`, ...). Idempotent on the recorded Access app id, like
2936
+ * `planOwners`: Cloudflare's live listing has no Access apps. The declared
2937
+ * gates (`planGates`) come first. Under a `'*'` gate, a bypass on each name
2938
+ * foundry owns comes before them, so no owned name is ever gated.
2927
2939
  */
2928
- function planAccess(org, recordedIds) {
2940
+ function planAccess(org, recordedIds, providerId, platformHostnames) {
2941
+ const { steps: gates, blocked } = planGates(org, recordedIds, providerId);
2929
2942
  if (!org.hosting.enabled)
2930
- return [];
2931
- // Only the `app` entry has a public name, and so only it can sit behind
2932
- // Access; per-entry hostnames are issue 309.
2933
- const appGate = org.hosting.entries
2934
- .filter((entry) => entry.key === APP_ENTRY_KEY && entry.accessEmails.length > 0)
2935
- .filter((entry) => !recordedIds[resourceKey('cloudflare', 'access', appStepKey(org, entry.key))])
2936
- .map((entry) => ({
2943
+ return { steps: gates, blocked };
2944
+ const owned = (hasWildcardGate(org) ? [...lockedHostnames(org, platformHostnames)].sort() : [])
2945
+ .filter((hostname) => !recordedIds[resourceKey('cloudflare', 'access', appStepKey(org, hostname))])
2946
+ .map((hostname) => ({
2937
2947
  action: {
2938
2948
  system: 'cloudflare',
2939
2949
  kind: 'access',
2940
- key: appStepKey(org, entry.key),
2941
- desired: {},
2950
+ key: appStepKey(org, hostname),
2951
+ desired: { ownedBypass: hostname },
2942
2952
  },
2943
2953
  step: {
2944
- description: `put ${org.slug}'s ${entry.key === APP_ENTRY_KEY ? 'app' : `${entry.key} app`} behind Cloudflare Access`,
2954
+ description: `let ${hostname} (${org.slug}) bypass Cloudflare Access: foundry owns the name and the '*' gate would cover it`,
2945
2955
  destroys: false,
2946
2956
  migrates: false,
2947
2957
  },
2948
2958
  }));
2949
- const ruleSteps = (org.hosting.access ?? [])
2959
+ const rules = (org.hosting.access ?? [])
2950
2960
  .filter((rule) => !recordedIds[resourceKey('cloudflare', 'access', appStepKey(org, rule.hostname))])
2951
2961
  .map((rule) => ({
2952
2962
  action: {
@@ -2961,13 +2971,11 @@ function planAccess(org, recordedIds) {
2961
2971
  migrates: false,
2962
2972
  },
2963
2973
  }));
2964
- return [...appGate, ...ruleSteps];
2974
+ return { steps: [...owned, ...gates, ...rules], blocked };
2965
2975
  }
2966
2976
  /** The plan description for one `hosting.access` rule, by kind (#246). */
2967
2977
  function accessRuleDescription(org, rule) {
2968
2978
  switch (rule.kind) {
2969
- case 'gate':
2970
- return `gate ${org.slug}'s ${rule.hostname} behind Cloudflare Access`;
2971
2979
  case 'bypass':
2972
2980
  return `let ${rule.hostname} (${org.slug}) bypass Cloudflare Access`;
2973
2981
  case 'service-token':
@@ -1,4 +1,4 @@
1
- import type { AccessDecision, createEdgeClient } from '@wtfalch/edge';
1
+ import type { AccessDecision, EnsureAccessProviderRequest, createEdgeClient } from '@wtfalch/edge';
2
2
  import { type WriteSlot } from './edge-dns.js';
3
3
  /** The edge SDK's platform Access client: `createEdgeClient({ ... }).platform.access`. */
4
4
  export type EdgePlatformAccess = ReturnType<typeof createEdgeClient>['platform']['access'];
@@ -47,7 +47,40 @@ export interface AccessPort {
47
47
  id: string;
48
48
  } | undefined>;
49
49
  deleteAccessApp(appId: string): Promise<void>;
50
+ /**
51
+ * A company's gate: an Access application over `hostnames`, one policy
52
+ * that requires the provider and the claim, and a first list of `emails`.
53
+ * A repeat with the same settings returns the stored gate. Foundry never
54
+ * changes the list of people after this call.
55
+ */
56
+ createGate(gate: AccessGateInput): Promise<{
57
+ id: string;
58
+ }>;
59
+ replaceGateHostnames(gateId: string, hostnames: string[]): Promise<void>;
60
+ /** Deletes the gate with its application, policy and list; a gate already gone is done. */
61
+ deleteGate(gateId: string): Promise<void>;
62
+ /** The Cloudflare Access team address (`<team>.cloudflareaccess.com`); rejects with `not_configured` while the Cloudflare token lacks an Access right. */
63
+ readiness(): Promise<{
64
+ authDomain: string;
65
+ }>;
66
+ /** Makes the OIDC sign-in provider, or updates the one of that name. Safe to repeat. The secret is sent to Cloudflare and never returned. */
67
+ ensureProvider(input: EnsureAccessProviderRequest): Promise<{
68
+ id: string;
69
+ }>;
70
+ }
71
+ export interface AccessGateInput {
72
+ organisationId: string;
73
+ /** The gate's key in the company. */
74
+ key: string;
75
+ /** The key of the app entry that declared the gate. */
76
+ entry: string;
77
+ hostnames: string[];
78
+ providerId: string;
79
+ claimName: string;
80
+ claimValue: string;
81
+ emails: string[];
50
82
  }
83
+ export declare const isNotFound: (error: unknown) => boolean;
51
84
  /**
52
85
  * Edge answers a policy, delete or create call for a live Access app with no
53
86
  * edge row `not_found` or 409 `exists` ("create or adopt it first"). So the
@@ -1,6 +1,6 @@
1
1
  import { EdgeApiError } from '@wtfalch/edge';
2
2
  import { EDGE_DNS_WRITE_CONCURRENCY, limiter, rateLimitRetrier, } from './edge-dns.js';
3
- const isNotFound = (error) => error instanceof EdgeApiError && error.code === 'not_found';
3
+ export const isNotFound = (error) => error instanceof EdgeApiError && error.code === 'not_found';
4
4
  /** Edge's 409 for a live app on the domain with no edge row. */
5
5
  const isExists = (error) => error instanceof EdgeApiError && error.code === 'conflict' && error.reason === 'exists';
6
6
  /**
@@ -27,6 +27,11 @@ export function createEdgeAccess(client, options = {}) {
27
27
  createPolicy: (appId, input) => retrying(() => client.createPolicy(appId, input)),
28
28
  findServiceToken: (name) => retrying(() => client.findServiceToken(name)),
29
29
  adopt: (input) => retrying(() => client.adopt(input)),
30
+ readiness: () => retrying(() => client.readiness()),
31
+ ensureProvider: (input) => retrying(() => client.ensureProvider(input)),
32
+ createGate: (input) => retrying(() => client.createGate(input)),
33
+ replaceGateHostnames: (id, input) => retrying(() => client.replaceGateHostnames(id, input)),
34
+ deleteGate: (id) => retrying(() => client.deleteGate(id)),
30
35
  };
31
36
  const writeSlot = options.writeSlot ?? limiter(EDGE_DNS_WRITE_CONCURRENCY);
32
37
  /** app id -> a seen live app with no edge row yet. */
@@ -119,5 +124,29 @@ export function createEdgeAccess(client, options = {}) {
119
124
  async deleteAccessApp(appId) {
120
125
  await onApp(appId, () => writeSlot(() => access.remove(appId)));
121
126
  },
127
+ async createGate(gate) {
128
+ const created = await writeSlot(() => access.createGate(gate));
129
+ return { id: created.id };
130
+ },
131
+ async replaceGateHostnames(gateId, hostnames) {
132
+ await writeSlot(() => access.replaceGateHostnames(gateId, { hostnames }));
133
+ },
134
+ async readiness() {
135
+ const { authDomain } = await access.readiness();
136
+ return { authDomain };
137
+ },
138
+ async ensureProvider(input) {
139
+ const { id } = await writeSlot(() => access.ensureProvider(input));
140
+ return { id };
141
+ },
142
+ async deleteGate(gateId) {
143
+ try {
144
+ await writeSlot(() => access.deleteGate(gateId));
145
+ }
146
+ catch (error) {
147
+ if (!isNotFound(error))
148
+ throw error;
149
+ }
150
+ },
122
151
  };
123
152
  }
@@ -747,6 +747,23 @@ export interface ZitadelPort {
747
747
  clientId: string;
748
748
  clientSecret: string;
749
749
  }>;
750
+ /**
751
+ * #604: a web client that signs in with a client secret (authorization
752
+ * code, `OIDC_AUTH_METHOD_TYPE_BASIC`), which Cloudflare Access needs.
753
+ * `createOidcApp` makes a public PKCE client with no secret, and
754
+ * `@wtfalch/providers` 0.7.5 has no call that makes this one, so the
755
+ * method is optional until it does; the platform's Access provider step
756
+ * is blocked without it. Like `createApiApp`, the secret is shown once.
757
+ */
758
+ createOidcAppWithSecret?(orgId: string, projectId: string, input: {
759
+ name: string;
760
+ redirectUris: string[];
761
+ postLogoutRedirectUris: string[];
762
+ }): Promise<{
763
+ appId: string;
764
+ clientId: string;
765
+ clientSecret: string;
766
+ }>;
750
767
  deleteApp(orgId: string, projectId: string, appId: string): Promise<void>;
751
768
  /**
752
769
  * `zitadelApp`'s kind-change path reads `apps` to find an app a prior,
@@ -1506,7 +1523,7 @@ export declare function callsHostnameFor(org: DeclaredOrg): string;
1506
1523
  * this file's hostname rules.
1507
1524
  */
1508
1525
  export declare function healthUrls(org: DeclaredOrg): string[];
1509
- /** The one Access policy foundry puts on its own Access app (#84). */
1526
+ /** The one Access policy foundry puts on a platform Access app (#84). */
1510
1527
  export declare const ACCESS_POLICY_NAME = "foundry: allowed emails";
1511
1528
  /**
1512
1529
  * Whether an application's own Coolify `status` (`` `running:healthy`,
@@ -1562,23 +1579,26 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1562
1579
  resourceId?: string;
1563
1580
  } | undefined>;
1564
1581
  /**
1565
- * `plan/diff.ts`'s `planAccess` (#84, widened by #246): puts a hostname
1566
- * behind a Cloudflare Access app. `action.key` tells the `app` entry's own
1567
- * gate (`accessEmails`, email-only) apart from a `hosting.access` rule on
1568
- * any other hostname -- `appStepKey` builds both, keyed by the entry's key
1569
- * or by the rule's hostname respectively, and the two never collide
1570
- * because an entry key never holds a `.` while a rule's hostname always
1571
- * does (`org.ts`'s `validateAccessRule`). The app-entry gate keeps the
1572
- * Access app name `foundry:<orgId>` it has always used, so a re-run
1573
- * against an org applied before #246 still finds the app it already
1574
- * created; a rule's own app is named `foundry:<orgId>:<hostname>` instead,
1575
- * since an org may declare more than one. Either way foundry reuses only
1576
- * an app with its own name (so a re-run after a crash does not duplicate
1577
- * it), refuses to adopt a hostname another Access app already holds
1578
- * (destroy would later delete it), and refuses when its own app carries
1579
- * any policy but foundry's, since that policy could let others in.
1582
+ * `plan/diff.ts`'s `planAccess` (#246): puts a `hosting.access` rule's
1583
+ * hostname behind a Cloudflare Access app named
1584
+ * `foundry:<orgId>:<hostname>`, since an org may declare more than one.
1585
+ * Foundry reuses only an app with its own name (so a re-run after a crash
1586
+ * does not duplicate it), refuses to adopt a hostname another Access app
1587
+ * already holds (destroy would later delete it), and refuses when its own
1588
+ * app carries any policy but foundry's, since that policy could let others
1589
+ * in.
1580
1590
  */
1581
1591
  private cloudflareAccess;
1592
+ /**
1593
+ * `plan/diff.ts`'s `planGates`: makes, changes or deletes one declared
1594
+ * gate through edge. A create needs the platform's recorded Access provider,
1595
+ * the company's sign-in organisation and an owner with an email, and fails
1596
+ * plainly without edge being called when one is missing. The create seeds
1597
+ * the list of people with the owners' emails; nothing here sets people
1598
+ * again. The gate id is recorded from the return value, the hostnames it
1599
+ * has last been made with as a scope id.
1600
+ */
1601
+ private cloudflareGate;
1582
1602
  /** The Cloudflare Access policy body for one `hosting.access` rule, by kind (#246). */
1583
1603
  private accessPolicyFor;
1584
1604
  /**