@wtfalch/foundry 0.23.0 → 0.25.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/bootstrap/declare.js +6 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/migrations/0048_hosting_gate_limit.sql +7 -0
- package/dist/migrations/0049_platform_uptime.sql +11 -0
- package/dist/org.d.ts +27 -23
- package/dist/org.js +73 -29
- package/dist/plan/diff.js +36 -28
- package/dist/plan/edge-access.d.ts +48 -1
- package/dist/plan/edge-access.js +51 -2
- package/dist/plan/edge-workers.d.ts +16 -1
- package/dist/plan/edge-workers.js +8 -0
- package/dist/plan/executor.d.ts +50 -17
- package/dist/plan/executor.js +93 -47
- package/dist/plan/gates.d.ts +42 -0
- package/dist/plan/gates.js +126 -0
- package/dist/plan/health-targets.js +3 -4
- package/dist/plan/index.d.ts +2 -1
- package/dist/plan/plan-apply.d.ts +6 -0
- package/dist/plan/plan-apply.js +8 -1
- package/dist/plan/platform-diff.d.ts +102 -0
- package/dist/plan/platform-diff.js +288 -0
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-org.js +4 -0
- package/dist/plan/platform-providers.d.ts +47 -0
- package/dist/plan/platform-providers.js +271 -14
- package/dist/plan/uptime-worker.d.ts +50 -0
- package/dist/plan/uptime-worker.js +229 -0
- package/dist/platform.d.ts +18 -0
- package/dist/platform.js +14 -0
- package/dist/store/postgres.js +31 -5
- package/dist/store/schema.d.ts +68 -0
- package/dist/store/schema.js +6 -0
- package/package.json +2 -2
|
@@ -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 },
|
|
@@ -145,5 +146,9 @@ export function declaredPlatformFor(apex, serverIpv4) {
|
|
|
145
146
|
apex,
|
|
146
147
|
...(serverIpv4 ? { serverIpv4 } : {}),
|
|
147
148
|
...services,
|
|
149
|
+
// Off, like `sfu`: the pin is an Archon release tag and digest, which exist
|
|
150
|
+
// only after Archon runs. Declared on with no pin, it would be reported
|
|
151
|
+
// blocked and the bootstrap would never be complete.
|
|
152
|
+
uptime: { enabled: false, tag: '', sha256: '' },
|
|
148
153
|
};
|
|
149
154
|
}
|
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';
|
|
@@ -41,7 +41,7 @@ export { checkMailIdentityAudience } from './plan/index.js';
|
|
|
41
41
|
export type { MailAllowlistApp } from './plan/index.js';
|
|
42
42
|
export { checkMailAppsAllowlist } from './plan/index.js';
|
|
43
43
|
export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
|
|
44
|
-
export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './plan/index.js';
|
|
44
|
+
export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeDeps, PlatformWorkersFactory, PlatformWorkersPort, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './plan/index.js';
|
|
45
45
|
export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './plan/index.js';
|
|
46
46
|
export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './plan/index.js';
|
|
47
47
|
export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.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,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;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- @wtfalch/foundry: the uptime Worker on the declared platform (foundry#542).
|
|
2
|
+
-- Mirrors `src/store/schema.ts`'s `foundryDeclaredPlatform` additions.
|
|
3
|
+
-- `uptime_tag` and `uptime_sha256` are the pin of the Archon release whose
|
|
4
|
+
-- built Worker foundry deploys; both empty means nothing is pinned yet, and
|
|
5
|
+
-- the planner reports that instead of planning the step. The Worker is on by
|
|
6
|
+
-- default, like every service except sfu and coolify.
|
|
7
|
+
|
|
8
|
+
alter table foundry_declared_platform
|
|
9
|
+
add column if not exists uptime_enabled boolean not null default true,
|
|
10
|
+
add column if not exists uptime_tag text not null default '',
|
|
11
|
+
add column if not exists uptime_sha256 text not null default '';
|
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
|
-
*
|
|
246
|
-
* (
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
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
|
|
255
|
-
export type AccessRuleKind = '
|
|
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
|
|
259
|
-
* (#246, `HostingConfig.access`). `hostname` is the full hostname
|
|
260
|
-
*
|
|
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 `
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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
|
-
*
|
|
321
|
-
*
|
|
317
|
+
* Cloudflare Access gates on this entry's own `hostnames`. Empty means no
|
|
318
|
+
* gate. Who passes is not declared here.
|
|
322
319
|
*/
|
|
323
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
|
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
|
-
/**
|
|
180
|
-
|
|
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 === '
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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 (
|
|
202
|
-
return
|
|
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
|
-
|
|
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
|
-
* #
|
|
2916
|
-
*
|
|
2917
|
-
*
|
|
2918
|
-
*
|
|
2919
|
-
*
|
|
2920
|
-
*
|
|
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
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
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,
|
|
2941
|
-
desired: {},
|
|
2950
|
+
key: appStepKey(org, hostname),
|
|
2951
|
+
desired: { ownedBypass: hostname },
|
|
2942
2952
|
},
|
|
2943
2953
|
step: {
|
|
2944
|
-
description: `
|
|
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
|
|
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 [...
|
|
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'];
|
|
@@ -29,6 +29,7 @@ export interface AccessPort {
|
|
|
29
29
|
listAccessPolicies(appId: string): Promise<Array<{
|
|
30
30
|
id: string;
|
|
31
31
|
name: string;
|
|
32
|
+
decision?: AccessDecision;
|
|
32
33
|
include?: AccessPolicyRule[];
|
|
33
34
|
}>>;
|
|
34
35
|
createAccessPolicy(appId: string, policy: {
|
|
@@ -46,8 +47,54 @@ export interface AccessPort {
|
|
|
46
47
|
findServiceTokenByName(name: string): Promise<{
|
|
47
48
|
id: string;
|
|
48
49
|
} | undefined>;
|
|
50
|
+
/**
|
|
51
|
+
* Mints a service token through edge (edge 0.12.0). `clientId` and
|
|
52
|
+
* `clientSecret` come back once and are never returned again; a token of that
|
|
53
|
+
* name, made by edge or not, answers `EdgeApiError` `conflict`.
|
|
54
|
+
*/
|
|
55
|
+
createServiceToken(name: string): Promise<{
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
clientId: string;
|
|
59
|
+
clientSecret: string;
|
|
60
|
+
}>;
|
|
61
|
+
/** Removes a token edge created. `not_found` for any other; a token already gone is success. */
|
|
62
|
+
removeServiceToken(id: string): Promise<void>;
|
|
49
63
|
deleteAccessApp(appId: string): Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* A company's gate: an Access application over `hostnames`, one policy
|
|
66
|
+
* that requires the provider and the claim, and a first list of `emails`.
|
|
67
|
+
* A repeat with the same settings returns the stored gate. Foundry never
|
|
68
|
+
* changes the list of people after this call.
|
|
69
|
+
*/
|
|
70
|
+
createGate(gate: AccessGateInput): Promise<{
|
|
71
|
+
id: string;
|
|
72
|
+
}>;
|
|
73
|
+
replaceGateHostnames(gateId: string, hostnames: string[]): Promise<void>;
|
|
74
|
+
/** Deletes the gate with its application, policy and list; a gate already gone is done. */
|
|
75
|
+
deleteGate(gateId: string): Promise<void>;
|
|
76
|
+
/** The Cloudflare Access team address (`<team>.cloudflareaccess.com`); rejects with `not_configured` while the Cloudflare token lacks an Access right. */
|
|
77
|
+
readiness(): Promise<{
|
|
78
|
+
authDomain: string;
|
|
79
|
+
}>;
|
|
80
|
+
/** 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. */
|
|
81
|
+
ensureProvider(input: EnsureAccessProviderRequest): Promise<{
|
|
82
|
+
id: string;
|
|
83
|
+
}>;
|
|
84
|
+
}
|
|
85
|
+
export interface AccessGateInput {
|
|
86
|
+
organisationId: string;
|
|
87
|
+
/** The gate's key in the company. */
|
|
88
|
+
key: string;
|
|
89
|
+
/** The key of the app entry that declared the gate. */
|
|
90
|
+
entry: string;
|
|
91
|
+
hostnames: string[];
|
|
92
|
+
providerId: string;
|
|
93
|
+
claimName: string;
|
|
94
|
+
claimValue: string;
|
|
95
|
+
emails: string[];
|
|
50
96
|
}
|
|
97
|
+
export declare const isNotFound: (error: unknown) => boolean;
|
|
51
98
|
/**
|
|
52
99
|
* Edge answers a policy, delete or create call for a live Access app with no
|
|
53
100
|
* edge row `not_found` or 409 `exists` ("create or adopt it first"). So the
|
package/dist/plan/edge-access.js
CHANGED
|
@@ -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
|
/**
|
|
@@ -26,7 +26,14 @@ export function createEdgeAccess(client, options = {}) {
|
|
|
26
26
|
listPolicies: (appId) => retrying(() => client.listPolicies(appId)),
|
|
27
27
|
createPolicy: (appId, input) => retrying(() => client.createPolicy(appId, input)),
|
|
28
28
|
findServiceToken: (name) => retrying(() => client.findServiceToken(name)),
|
|
29
|
+
createServiceToken: (input) => retrying(() => client.createServiceToken(input)),
|
|
30
|
+
removeServiceToken: (id) => retrying(() => client.removeServiceToken(id)),
|
|
29
31
|
adopt: (input) => retrying(() => client.adopt(input)),
|
|
32
|
+
readiness: () => retrying(() => client.readiness()),
|
|
33
|
+
ensureProvider: (input) => retrying(() => client.ensureProvider(input)),
|
|
34
|
+
createGate: (input) => retrying(() => client.createGate(input)),
|
|
35
|
+
replaceGateHostnames: (id, input) => retrying(() => client.replaceGateHostnames(id, input)),
|
|
36
|
+
deleteGate: (id) => retrying(() => client.deleteGate(id)),
|
|
30
37
|
};
|
|
31
38
|
const writeSlot = options.writeSlot ?? limiter(EDGE_DNS_WRITE_CONCURRENCY);
|
|
32
39
|
/** app id -> a seen live app with no edge row yet. */
|
|
@@ -98,7 +105,12 @@ export function createEdgeAccess(client, options = {}) {
|
|
|
98
105
|
},
|
|
99
106
|
async listAccessPolicies(appId) {
|
|
100
107
|
const { policies } = await onApp(appId, () => access.listPolicies(appId));
|
|
101
|
-
return policies.map((p) => ({
|
|
108
|
+
return policies.map((p) => ({
|
|
109
|
+
id: p.id,
|
|
110
|
+
name: p.name,
|
|
111
|
+
decision: p.decision,
|
|
112
|
+
include: p.include,
|
|
113
|
+
}));
|
|
102
114
|
},
|
|
103
115
|
async createAccessPolicy(appId, policy) {
|
|
104
116
|
const created = await onApp(appId, () => writeSlot(() => access.createPolicy(appId, policy)));
|
|
@@ -116,8 +128,45 @@ export function createEdgeAccess(client, options = {}) {
|
|
|
116
128
|
throw error;
|
|
117
129
|
}
|
|
118
130
|
},
|
|
131
|
+
async createServiceToken(name) {
|
|
132
|
+
// Not retried on anything but `rate_limited`: a second mint would be a second token.
|
|
133
|
+
const minted = await writeSlot(() => access.createServiceToken({ name }));
|
|
134
|
+
return {
|
|
135
|
+
id: minted.token.id,
|
|
136
|
+
name: minted.token.name,
|
|
137
|
+
clientId: minted.clientId,
|
|
138
|
+
clientSecret: minted.clientSecret,
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
async removeServiceToken(id) {
|
|
142
|
+
await writeSlot(() => access.removeServiceToken(id));
|
|
143
|
+
},
|
|
119
144
|
async deleteAccessApp(appId) {
|
|
120
145
|
await onApp(appId, () => writeSlot(() => access.remove(appId)));
|
|
121
146
|
},
|
|
147
|
+
async createGate(gate) {
|
|
148
|
+
const created = await writeSlot(() => access.createGate(gate));
|
|
149
|
+
return { id: created.id };
|
|
150
|
+
},
|
|
151
|
+
async replaceGateHostnames(gateId, hostnames) {
|
|
152
|
+
await writeSlot(() => access.replaceGateHostnames(gateId, { hostnames }));
|
|
153
|
+
},
|
|
154
|
+
async readiness() {
|
|
155
|
+
const { authDomain } = await access.readiness();
|
|
156
|
+
return { authDomain };
|
|
157
|
+
},
|
|
158
|
+
async ensureProvider(input) {
|
|
159
|
+
const { id } = await writeSlot(() => access.ensureProvider(input));
|
|
160
|
+
return { id };
|
|
161
|
+
},
|
|
162
|
+
async deleteGate(gateId) {
|
|
163
|
+
try {
|
|
164
|
+
await writeSlot(() => access.deleteGate(gateId));
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
if (!isNotFound(error))
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
},
|
|
122
171
|
};
|
|
123
172
|
}
|
|
@@ -5,6 +5,21 @@ import type { WorkersPort } from './executor.js';
|
|
|
5
5
|
export type EdgePlatformWorkers = ReturnType<typeof createEdgeClient>['platform']['workers'];
|
|
6
6
|
/** Edge answers a first upload of a name that another upload is making, or a delete in flight, `conflict` + `busy`. */
|
|
7
7
|
export declare const EDGE_WORKERS_BUSY_ATTEMPTS = 5;
|
|
8
|
+
/**
|
|
9
|
+
* What the platform's uptime Worker step needs beyond {@link WorkersPort}
|
|
10
|
+
* (edge 0.11.0): cron triggers and write-only secrets, both for a script edge
|
|
11
|
+
* owns. A secret with a `version` edge already holds is skipped; a new
|
|
12
|
+
* `version` sets the value again. Edge never returns a value, and nothing here
|
|
13
|
+
* keeps one.
|
|
14
|
+
*/
|
|
15
|
+
export interface PlatformWorkersPort extends WorkersPort {
|
|
16
|
+
setSchedules(name: string, crons: readonly string[]): Promise<void>;
|
|
17
|
+
setSecrets(name: string, secrets: readonly {
|
|
18
|
+
name: string;
|
|
19
|
+
value: string;
|
|
20
|
+
version: string;
|
|
21
|
+
}[]): Promise<void>;
|
|
22
|
+
}
|
|
8
23
|
/** What the host supplies: fetches the artifact and returns its bytes, verified against `sha256`. Foundry never fetches. */
|
|
9
24
|
export interface EdgeWorkersOptions {
|
|
10
25
|
fetchArtifact: (url: string, sha256: string) => Promise<Uint8Array>;
|
|
@@ -28,4 +43,4 @@ export interface EdgeWorkersOptions {
|
|
|
28
43
|
* foundry refuses to overwrite is never adopted. A 404 on a delete means gone.
|
|
29
44
|
* A `rate_limited` or `busy` answer is retried.
|
|
30
45
|
*/
|
|
31
|
-
export declare function createEdgeWorkers(client: EdgePlatformWorkers, options: EdgeWorkersOptions):
|
|
46
|
+
export declare function createEdgeWorkers(client: EdgePlatformWorkers, options: EdgeWorkersOptions): PlatformWorkersPort;
|
|
@@ -149,6 +149,14 @@ export function createEdgeWorkers(client, options) {
|
|
|
149
149
|
}
|
|
150
150
|
adoptedRoutes.delete(`${zoneId}/${routeId}`);
|
|
151
151
|
},
|
|
152
|
+
async setSchedules(name, crons) {
|
|
153
|
+
await adoptScript(name);
|
|
154
|
+
await writeSlot(() => retrying(() => client.setSchedules(name, { crons: [...crons] })));
|
|
155
|
+
},
|
|
156
|
+
async setSecrets(name, secrets) {
|
|
157
|
+
await adoptScript(name);
|
|
158
|
+
await writeSlot(() => retrying(() => client.setSecrets(name, { secrets: secrets.map((s) => ({ ...s })) })));
|
|
159
|
+
},
|
|
152
160
|
async deleteScript(name) {
|
|
153
161
|
if (!(await adoptScript(name)))
|
|
154
162
|
return;
|