@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
package/dist/plan/executor.d.ts
CHANGED
|
@@ -449,6 +449,19 @@ export interface WorkersRateLimitBinding {
|
|
|
449
449
|
period: number;
|
|
450
450
|
};
|
|
451
451
|
}
|
|
452
|
+
/** A Workers KV namespace binding (edge 0.11.0): edge reuses the namespace titled `title` or creates it, and deletes one it created with the script. */
|
|
453
|
+
export interface WorkersKvBinding {
|
|
454
|
+
type: 'kv_namespace';
|
|
455
|
+
name: string;
|
|
456
|
+
title: string;
|
|
457
|
+
}
|
|
458
|
+
/** A plain text variable binding (edge 0.11.0). Never for a secret: those go through {@link PlatformWorkersPort.setSecrets}. */
|
|
459
|
+
export interface WorkersPlainTextBinding {
|
|
460
|
+
type: 'plain_text';
|
|
461
|
+
name: string;
|
|
462
|
+
text: string;
|
|
463
|
+
}
|
|
464
|
+
export type WorkersBinding = WorkersRateLimitBinding | WorkersKvBinding | WorkersPlainTextBinding;
|
|
452
465
|
/**
|
|
453
466
|
* The Workers calls `cloudflareWorker` makes (#318, F8), served by edge's
|
|
454
467
|
* `platform.workers` through `createEdgeWorkers` (`edge-workers.ts`): deploys
|
|
@@ -484,7 +497,7 @@ export interface WorkersPort {
|
|
|
484
497
|
artifactUrl: string;
|
|
485
498
|
artifactSha256: string;
|
|
486
499
|
compatibilityDate: string;
|
|
487
|
-
bindings?:
|
|
500
|
+
bindings?: WorkersBinding[];
|
|
488
501
|
}): Promise<void>;
|
|
489
502
|
/** The script name currently bound to `pattern` in `zoneId`, or `undefined` for no route there. */
|
|
490
503
|
findRoute(zoneId: string, pattern: string): Promise<{
|
|
@@ -747,6 +760,23 @@ export interface ZitadelPort {
|
|
|
747
760
|
clientId: string;
|
|
748
761
|
clientSecret: string;
|
|
749
762
|
}>;
|
|
763
|
+
/**
|
|
764
|
+
* #604: a web client that signs in with a client secret (authorization
|
|
765
|
+
* code, `OIDC_AUTH_METHOD_TYPE_BASIC`), which Cloudflare Access needs.
|
|
766
|
+
* `createOidcApp` makes a public PKCE client with no secret, and
|
|
767
|
+
* `@wtfalch/providers` 0.7.5 has no call that makes this one, so the
|
|
768
|
+
* method is optional until it does; the platform's Access provider step
|
|
769
|
+
* is blocked without it. Like `createApiApp`, the secret is shown once.
|
|
770
|
+
*/
|
|
771
|
+
createOidcAppWithSecret?(orgId: string, projectId: string, input: {
|
|
772
|
+
name: string;
|
|
773
|
+
redirectUris: string[];
|
|
774
|
+
postLogoutRedirectUris: string[];
|
|
775
|
+
}): Promise<{
|
|
776
|
+
appId: string;
|
|
777
|
+
clientId: string;
|
|
778
|
+
clientSecret: string;
|
|
779
|
+
}>;
|
|
750
780
|
deleteApp(orgId: string, projectId: string, appId: string): Promise<void>;
|
|
751
781
|
/**
|
|
752
782
|
* `zitadelApp`'s kind-change path reads `apps` to find an app a prior,
|
|
@@ -1506,7 +1536,7 @@ export declare function callsHostnameFor(org: DeclaredOrg): string;
|
|
|
1506
1536
|
* this file's hostname rules.
|
|
1507
1537
|
*/
|
|
1508
1538
|
export declare function healthUrls(org: DeclaredOrg): string[];
|
|
1509
|
-
/** The one Access policy foundry puts on
|
|
1539
|
+
/** The one Access policy foundry puts on a platform Access app (#84). */
|
|
1510
1540
|
export declare const ACCESS_POLICY_NAME = "foundry: allowed emails";
|
|
1511
1541
|
/**
|
|
1512
1542
|
* Whether an application's own Coolify `status` (`` `running:healthy`,
|
|
@@ -1562,23 +1592,26 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1562
1592
|
resourceId?: string;
|
|
1563
1593
|
} | undefined>;
|
|
1564
1594
|
/**
|
|
1565
|
-
* `plan/diff.ts`'s `planAccess` (#
|
|
1566
|
-
* behind a Cloudflare Access app
|
|
1567
|
-
*
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
1570
|
-
*
|
|
1571
|
-
*
|
|
1572
|
-
*
|
|
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.
|
|
1595
|
+
* `plan/diff.ts`'s `planAccess` (#246): puts a `hosting.access` rule's
|
|
1596
|
+
* hostname behind a Cloudflare Access app named
|
|
1597
|
+
* `foundry:<orgId>:<hostname>`, since an org may declare more than one.
|
|
1598
|
+
* Foundry reuses only an app with its own name (so a re-run after a crash
|
|
1599
|
+
* does not duplicate it), refuses to adopt a hostname another Access app
|
|
1600
|
+
* already holds (destroy would later delete it), and refuses when its own
|
|
1601
|
+
* app carries any policy but foundry's, since that policy could let others
|
|
1602
|
+
* in.
|
|
1580
1603
|
*/
|
|
1581
1604
|
private cloudflareAccess;
|
|
1605
|
+
/**
|
|
1606
|
+
* `plan/diff.ts`'s `planGates`: makes, changes or deletes one declared
|
|
1607
|
+
* gate through edge. A create needs the platform's recorded Access provider,
|
|
1608
|
+
* the company's sign-in organisation and an owner with an email, and fails
|
|
1609
|
+
* plainly without edge being called when one is missing. The create seeds
|
|
1610
|
+
* the list of people with the owners' emails; nothing here sets people
|
|
1611
|
+
* again. The gate id is recorded from the return value, the hostnames it
|
|
1612
|
+
* has last been made with as a scope id.
|
|
1613
|
+
*/
|
|
1614
|
+
private cloudflareGate;
|
|
1582
1615
|
/** The Cloudflare Access policy body for one `hosting.access` rule, by kind (#246). */
|
|
1583
1616
|
private accessPolicyFor;
|
|
1584
1617
|
/**
|
package/dist/plan/executor.js
CHANGED
|
@@ -11,7 +11,7 @@ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
|
11
11
|
import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
|
|
12
12
|
import { waitForNewDeployment, } from './deploy-health.js';
|
|
13
13
|
import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
|
|
14
|
-
import { createEdgeAccess, } from './edge-access.js';
|
|
14
|
+
import { createEdgeAccess, isNotFound as isEdgeNotFound, } from './edge-access.js';
|
|
15
15
|
import { EDGE_DNS_WRITE_CONCURRENCY, createEdgeDns, limiter, } from './edge-dns.js';
|
|
16
16
|
import { createEdgeRateLimits, } from './edge-ratelimits.js';
|
|
17
17
|
import { createEdgeTunnels } from './edge-tunnels.js';
|
|
@@ -19,6 +19,7 @@ import { createEdgeZones } from './edge-zones.js';
|
|
|
19
19
|
import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
|
|
20
20
|
import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, edgeKeyAppRecordKey, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
|
|
21
21
|
import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
|
|
22
|
+
import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate } from './gates.js';
|
|
22
23
|
import { healthCheckSettings } from './health-check-settings.js';
|
|
23
24
|
import { appHostname, coolifyTunnelDomains, entryHostnames, tunnelDnsHostnames, tunnelDomainList, tunnelIngressHostnames, } from './hostnames.js';
|
|
24
25
|
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
@@ -184,18 +185,14 @@ function mailOrgFor(org, usesDomain, mode) {
|
|
|
184
185
|
* value.
|
|
185
186
|
*/
|
|
186
187
|
const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
|
|
187
|
-
/** The one Access policy foundry puts on
|
|
188
|
+
/** The one Access policy foundry puts on a platform Access app (#84). */
|
|
188
189
|
export const ACCESS_POLICY_NAME = 'foundry: allowed emails';
|
|
189
190
|
/**
|
|
190
191
|
* The Access policy name for each `hosting.access` rule kind (#246),
|
|
191
192
|
* `cloudflareAccess`'s way of telling its own policy apart from a foreign
|
|
192
|
-
* one on the same app
|
|
193
|
-
* the `app` entry's own email gate, which keeps that exact name so a
|
|
194
|
-
* re-run against an org applied before #246 still recognises its own
|
|
195
|
-
* policy.
|
|
193
|
+
* one on the same app.
|
|
196
194
|
*/
|
|
197
195
|
const ACCESS_RULE_POLICY_NAMES = {
|
|
198
|
-
gate: ACCESS_POLICY_NAME,
|
|
199
196
|
bypass: 'foundry: bypass',
|
|
200
197
|
'service-token': 'foundry: service token',
|
|
201
198
|
};
|
|
@@ -721,13 +718,10 @@ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
721
718
|
* redirect or 403.
|
|
722
719
|
*/
|
|
723
720
|
function publicHealthCheck(org, recorded, hostname, path, options = {}) {
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
(app !== undefined &&
|
|
727
|
-
app.accessEmails.length > 0 &&
|
|
728
|
-
entryHostnames(org, app).includes(hostname));
|
|
729
|
-
if (behindAccess)
|
|
721
|
+
if ((org.hosting.access ?? []).some((rule) => rule.hostname === hostname) ||
|
|
722
|
+
isBehindGate(org, hostname)) {
|
|
730
723
|
return { kind: 'deployment-only' };
|
|
724
|
+
}
|
|
731
725
|
const routed = (host) => !org.domain ||
|
|
732
726
|
!(host === org.domain || host.endsWith(`.${org.domain}`)) ||
|
|
733
727
|
(recorded[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY] ?? '').split(',').includes(host);
|
|
@@ -798,6 +792,8 @@ export class FoundryStepExecutor {
|
|
|
798
792
|
return this.cloudflareZone(action, org, context);
|
|
799
793
|
case 'cloudflare:access':
|
|
800
794
|
return this.cloudflareAccess(action, org);
|
|
795
|
+
case 'cloudflare:gate':
|
|
796
|
+
return this.cloudflareGate(action, org, context);
|
|
801
797
|
case 'cloudflare:ratelimit':
|
|
802
798
|
return this.cloudflareRateLimit(org);
|
|
803
799
|
case 'cloudflare:worker':
|
|
@@ -843,40 +839,27 @@ export class FoundryStepExecutor {
|
|
|
843
839
|
}
|
|
844
840
|
}
|
|
845
841
|
/**
|
|
846
|
-
* `plan/diff.ts`'s `planAccess` (#
|
|
847
|
-
* behind a Cloudflare Access app
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
* against an org applied before #246 still finds the app it already
|
|
855
|
-
* created; a rule's own app is named `foundry:<orgId>:<hostname>` instead,
|
|
856
|
-
* since an org may declare more than one. Either way foundry reuses only
|
|
857
|
-
* an app with its own name (so a re-run after a crash does not duplicate
|
|
858
|
-
* it), refuses to adopt a hostname another Access app already holds
|
|
859
|
-
* (destroy would later delete it), and refuses when its own app carries
|
|
860
|
-
* any policy but foundry's, since that policy could let others in.
|
|
842
|
+
* `plan/diff.ts`'s `planAccess` (#246): puts a `hosting.access` rule's
|
|
843
|
+
* hostname behind a Cloudflare Access app named
|
|
844
|
+
* `foundry:<orgId>:<hostname>`, since an org may declare more than one.
|
|
845
|
+
* Foundry reuses only an app with its own name (so a re-run after a crash
|
|
846
|
+
* does not duplicate it), refuses to adopt a hostname another Access app
|
|
847
|
+
* already holds (destroy would later delete it), and refuses when its own
|
|
848
|
+
* app carries any policy but foundry's, since that policy could let others
|
|
849
|
+
* in.
|
|
861
850
|
*/
|
|
862
851
|
async cloudflareAccess(action, org) {
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
throw new Error(`cloudflare:access for ${org.slug}: no '${APP_ENTRY_KEY}' hosting entry`);
|
|
868
|
-
if (action.key === appStepKey(org, entry.key)) {
|
|
852
|
+
const rule = (org.hosting.access ?? []).find((r) => appStepKey(org, r.hostname) === action.key);
|
|
853
|
+
const owned = action.desired.ownedBypass;
|
|
854
|
+
if (!rule && owned && appStepKey(org, owned) === action.key) {
|
|
855
|
+
// A name foundry owns under a '*' gate: the same bypass a declared rule makes.
|
|
869
856
|
return this.cloudflareAccessApp(org, {
|
|
870
|
-
appName: `foundry:${org.id}`,
|
|
871
|
-
domain:
|
|
872
|
-
policyName:
|
|
873
|
-
policy: {
|
|
874
|
-
decision: 'allow',
|
|
875
|
-
include: entry.accessEmails.map((email) => ({ email: { email } })),
|
|
876
|
-
},
|
|
857
|
+
appName: `foundry:${org.id}:${owned}`,
|
|
858
|
+
domain: owned,
|
|
859
|
+
policyName: ACCESS_RULE_POLICY_NAMES.bypass,
|
|
860
|
+
policy: await this.accessPolicyFor(org, { kind: 'bypass', hostname: owned }),
|
|
877
861
|
});
|
|
878
862
|
}
|
|
879
|
-
const rule = (org.hosting.access ?? []).find((r) => appStepKey(org, r.hostname) === action.key);
|
|
880
863
|
if (!rule) {
|
|
881
864
|
throw new Error(`cloudflare:access for ${org.slug}: no hosting.access rule matches step key '${action.key}'`);
|
|
882
865
|
}
|
|
@@ -887,14 +870,74 @@ export class FoundryStepExecutor {
|
|
|
887
870
|
policy: await this.accessPolicyFor(org, rule),
|
|
888
871
|
});
|
|
889
872
|
}
|
|
873
|
+
/**
|
|
874
|
+
* `plan/diff.ts`'s `planGates`: makes, changes or deletes one declared
|
|
875
|
+
* gate through edge. A create needs the platform's recorded Access provider,
|
|
876
|
+
* the company's sign-in organisation and an owner with an email, and fails
|
|
877
|
+
* plainly without edge being called when one is missing. The create seeds
|
|
878
|
+
* the list of people with the owners' emails; nothing here sets people
|
|
879
|
+
* again. The gate id is recorded from the return value, the hostnames it
|
|
880
|
+
* has last been made with as a scope id.
|
|
881
|
+
*/
|
|
882
|
+
async cloudflareGate(action, org, context) {
|
|
883
|
+
const gateKey = action.key.slice(org.id.length + 1);
|
|
884
|
+
const at = `cloudflare:gate for ${org.slug} gate '${gateKey}'`;
|
|
885
|
+
const desired = action.desired;
|
|
886
|
+
const recorded = context.recordedIds();
|
|
887
|
+
const gateId = recorded[gateIdRecordKey(org.id, gateKey)];
|
|
888
|
+
const hostnamesKey = gateHostnamesScopeKey(gateKey);
|
|
889
|
+
if (desired.mode === 'delete') {
|
|
890
|
+
if (gateId)
|
|
891
|
+
await this.accessPort().deleteGate(gateId);
|
|
892
|
+
if (context.forgetId) {
|
|
893
|
+
context.forgetId(gateIdRecordKey(org.id, gateKey));
|
|
894
|
+
context.forgetId(hostnamesKey);
|
|
895
|
+
}
|
|
896
|
+
return {};
|
|
897
|
+
}
|
|
898
|
+
const hostnames = desired.hostnames ?? [];
|
|
899
|
+
if (gateId) {
|
|
900
|
+
try {
|
|
901
|
+
await this.accessPort().replaceGateHostnames(gateId, hostnames);
|
|
902
|
+
}
|
|
903
|
+
catch (error) {
|
|
904
|
+
if (!isEdgeNotFound(error))
|
|
905
|
+
throw error;
|
|
906
|
+
// The gate is gone at edge: forget it, so the next plan creates it again.
|
|
907
|
+
context.forgetId?.(gateIdRecordKey(org.id, gateKey));
|
|
908
|
+
context.forgetId?.(hostnamesKey);
|
|
909
|
+
return {};
|
|
910
|
+
}
|
|
911
|
+
context.recordScopeId(hostnamesKey, JSON.stringify(hostnames));
|
|
912
|
+
return {};
|
|
913
|
+
}
|
|
914
|
+
if (!desired.providerId) {
|
|
915
|
+
throw new Error(`${at}: the platform's sign-in provider step has not been applied yet, so there is no Access provider to require`);
|
|
916
|
+
}
|
|
917
|
+
const claimValue = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
918
|
+
if (!claimValue) {
|
|
919
|
+
throw new Error(`${at}: ${org.slug} has no sign-in organisation recorded yet`);
|
|
920
|
+
}
|
|
921
|
+
const emails = org.owners.map((owner) => owner.email);
|
|
922
|
+
if (emails.length === 0) {
|
|
923
|
+
throw new Error(`${at}: ${org.slug} declares no owner with an email, and a gate nobody can pass is a mistake`);
|
|
924
|
+
}
|
|
925
|
+
const created = await this.accessPort().createGate({
|
|
926
|
+
organisationId: org.id,
|
|
927
|
+
key: gateKey,
|
|
928
|
+
entry: desired.entry,
|
|
929
|
+
hostnames,
|
|
930
|
+
providerId: desired.providerId,
|
|
931
|
+
claimName: GATE_CLAIM_NAME,
|
|
932
|
+
claimValue,
|
|
933
|
+
emails,
|
|
934
|
+
});
|
|
935
|
+
context.recordScopeId(hostnamesKey, JSON.stringify(hostnames));
|
|
936
|
+
return { resourceId: created.id };
|
|
937
|
+
}
|
|
890
938
|
/** The Cloudflare Access policy body for one `hosting.access` rule, by kind (#246). */
|
|
891
939
|
async accessPolicyFor(org, rule) {
|
|
892
940
|
switch (rule.kind) {
|
|
893
|
-
case 'gate':
|
|
894
|
-
return {
|
|
895
|
-
decision: 'allow',
|
|
896
|
-
include: rule.emails.map((email) => ({ email: { email } })),
|
|
897
|
-
};
|
|
898
941
|
case 'bypass':
|
|
899
942
|
// A bypass policy still needs an `include` rule to match against;
|
|
900
943
|
// `everyone` is Cloudflare's own shape for "match every visitor".
|
|
@@ -5996,6 +6039,9 @@ export class FoundryStepExecutor {
|
|
|
5996
6039
|
case 'cloudflare:access':
|
|
5997
6040
|
await this.accessPort().deleteAccessApp(target.id);
|
|
5998
6041
|
return;
|
|
6042
|
+
case 'cloudflare:gate':
|
|
6043
|
+
await this.accessPort().deleteGate(target.id);
|
|
6044
|
+
return;
|
|
5999
6045
|
case 'ai:key': {
|
|
6000
6046
|
// F6a: an entry's key (`<orgId>:<entry>`). REVOKE SEAM (ai#157): a
|
|
6001
6047
|
// port with no `revokeKey` leaves it live until it expires.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* A company's declared gates (`HostingEntry.gates`) as Cloudflare Access gates
|
|
5
|
+
* made through edge. Foundry sets who passes once, on create, from the
|
|
6
|
+
* company's owners; it never writes the list of people again, so a company's
|
|
7
|
+
* admin app owns it from then on.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The platform's recorded id of the Access provider that signs people in
|
|
11
|
+
* through the platform's ZITADEL. The platform's sign-in provider step writes
|
|
12
|
+
* it; a gate is never made without it. A flat scope key, no `:`.
|
|
13
|
+
*/
|
|
14
|
+
export declare const ACCESS_PROVIDER_RECORD_KEY = "access_provider_id";
|
|
15
|
+
/** The claim every gate requires: the company's ZITADEL organisation id, on every token ZITADEL issues. */
|
|
16
|
+
export declare const GATE_CLAIM_NAME = "urn:zitadel:iam:user:resourceowner:id";
|
|
17
|
+
/** The gate id edge returned, recorded by `runSteps` from the step's `resourceId`. */
|
|
18
|
+
export declare const gateIdRecordKey: (orgId: string, gateKey: string) => string;
|
|
19
|
+
/** The resolved, sorted hostnames the gate was last made or changed with, as JSON. */
|
|
20
|
+
export declare const gateHostnamesScopeKey: (gateKey: string) => string;
|
|
21
|
+
/** `'@'` is the domain, `'*'` is `*.<domain>`, a label is `<label>.<domain>`. Sorted. */
|
|
22
|
+
export declare function resolveGateHostnames(domain: string, names: readonly string[]): string[];
|
|
23
|
+
/** True when a declared gate resolves to `*.<domain>`. */
|
|
24
|
+
export declare function hasWildcardGate(org: DeclaredOrg): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* True when a declared gate covers `hostname`, exactly or through a gated
|
|
27
|
+
* wildcard, unless a `bypass` rule names it exactly: that rule wins. A name
|
|
28
|
+
* foundry owns is never behind a gate: the plan bypasses it under a wildcard.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isBehindGate(org: DeclaredOrg, hostname: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* One step per declared gate whose recorded id or hostnames differ from what
|
|
33
|
+
* is declared (a create, or a hostname change), and one destroying step per
|
|
34
|
+
* recorded gate no longer declared. `providerId` is the platform's recorded
|
|
35
|
+
* provider. A create needs it, the company's sign-in organisation and an
|
|
36
|
+
* owner with an email: when one is missing no create is planned and
|
|
37
|
+
* `blocked` says why (CLAUDE.md rule 2).
|
|
38
|
+
*/
|
|
39
|
+
export declare function planGates(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, providerId: string | null): {
|
|
40
|
+
steps: DraftStep[];
|
|
41
|
+
blocked: string | null;
|
|
42
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
|
|
2
|
+
import { lockedHostnames } from './locked-hostnames.js';
|
|
3
|
+
import { appStepKey } from './platform-diff.js';
|
|
4
|
+
/**
|
|
5
|
+
* A company's declared gates (`HostingEntry.gates`) as Cloudflare Access gates
|
|
6
|
+
* made through edge. Foundry sets who passes once, on create, from the
|
|
7
|
+
* company's owners; it never writes the list of people again, so a company's
|
|
8
|
+
* admin app owns it from then on.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The platform's recorded id of the Access provider that signs people in
|
|
12
|
+
* through the platform's ZITADEL. The platform's sign-in provider step writes
|
|
13
|
+
* it; a gate is never made without it. A flat scope key, no `:`.
|
|
14
|
+
*/
|
|
15
|
+
export const ACCESS_PROVIDER_RECORD_KEY = 'access_provider_id';
|
|
16
|
+
/** The claim every gate requires: the company's ZITADEL organisation id, on every token ZITADEL issues. */
|
|
17
|
+
export const GATE_CLAIM_NAME = 'urn:zitadel:iam:user:resourceowner:id';
|
|
18
|
+
/** The gate id edge returned, recorded by `runSteps` from the step's `resourceId`. */
|
|
19
|
+
export const gateIdRecordKey = (orgId, gateKey) => `cloudflare:gate:${orgId}:${gateKey}`;
|
|
20
|
+
/** The resolved, sorted hostnames the gate was last made or changed with, as JSON. */
|
|
21
|
+
export const gateHostnamesScopeKey = (gateKey) => `gate_hostnames_${gateKey}`;
|
|
22
|
+
/** `'@'` is the domain, `'*'` is `*.<domain>`, a label is `<label>.<domain>`. Sorted. */
|
|
23
|
+
export function resolveGateHostnames(domain, names) {
|
|
24
|
+
return names
|
|
25
|
+
.map((name) => (name === '@' ? domain : name === '*' ? `*.${domain}` : `${name}.${domain}`))
|
|
26
|
+
.sort();
|
|
27
|
+
}
|
|
28
|
+
/** Every declared gate's key with its entry's key and resolved hostnames; none without hosting or a domain. */
|
|
29
|
+
function declaredGates(org) {
|
|
30
|
+
const gates = new Map();
|
|
31
|
+
if (!org.hosting.enabled || !org.domain)
|
|
32
|
+
return gates;
|
|
33
|
+
for (const entry of org.hosting.entries) {
|
|
34
|
+
for (const gate of entry.gates) {
|
|
35
|
+
gates.set(gate.key, {
|
|
36
|
+
entry: entry.key,
|
|
37
|
+
hostnames: resolveGateHostnames(org.domain, gate.hostnames),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return gates;
|
|
42
|
+
}
|
|
43
|
+
/** True when a declared gate resolves to `*.<domain>`. */
|
|
44
|
+
export function hasWildcardGate(org) {
|
|
45
|
+
return [...declaredGates(org).values()].some(({ hostnames }) => hostnames.some((name) => name.startsWith('*.')));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* True when a declared gate covers `hostname`, exactly or through a gated
|
|
49
|
+
* wildcard, unless a `bypass` rule names it exactly: that rule wins. A name
|
|
50
|
+
* foundry owns is never behind a gate: the plan bypasses it under a wildcard.
|
|
51
|
+
*/
|
|
52
|
+
export function isBehindGate(org, hostname) {
|
|
53
|
+
if (lockedHostnames(org, []).has(hostname))
|
|
54
|
+
return false;
|
|
55
|
+
if ((org.hosting.access ?? []).some((r) => r.kind === 'bypass' && r.hostname === hostname)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
return [...declaredGates(org).values()].some(({ hostnames }) => hostnames.some((name) => name.startsWith('*.') ? hostname.endsWith(name.slice(1)) : name === hostname));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* One step per declared gate whose recorded id or hostnames differ from what
|
|
62
|
+
* is declared (a create, or a hostname change), and one destroying step per
|
|
63
|
+
* recorded gate no longer declared. `providerId` is the platform's recorded
|
|
64
|
+
* provider. A create needs it, the company's sign-in organisation and an
|
|
65
|
+
* owner with an email: when one is missing no create is planned and
|
|
66
|
+
* `blocked` says why (CLAUDE.md rule 2).
|
|
67
|
+
*/
|
|
68
|
+
export function planGates(org, recordedIds, providerId) {
|
|
69
|
+
const steps = [];
|
|
70
|
+
let blocked = null;
|
|
71
|
+
const missing = !providerId
|
|
72
|
+
? "the platform's sign-in provider step has not been applied yet, so there is no Access provider to require"
|
|
73
|
+
: !recordedIds[ZITADEL_ORG_SCOPE_KEY]
|
|
74
|
+
? `${org.slug} has no sign-in organisation recorded yet`
|
|
75
|
+
: org.owners.length === 0
|
|
76
|
+
? `${org.slug} declares no owner with an email, and a gate nobody can pass is a mistake`
|
|
77
|
+
: null;
|
|
78
|
+
const declared = declaredGates(org);
|
|
79
|
+
const prefix = gateIdRecordKey(org.id, '');
|
|
80
|
+
for (const [name, id] of Object.entries(recordedIds)) {
|
|
81
|
+
if (!id || !name.startsWith(prefix))
|
|
82
|
+
continue;
|
|
83
|
+
const gateKey = name.slice(prefix.length);
|
|
84
|
+
if (declared.has(gateKey))
|
|
85
|
+
continue;
|
|
86
|
+
steps.push({
|
|
87
|
+
action: {
|
|
88
|
+
system: 'cloudflare',
|
|
89
|
+
kind: 'gate',
|
|
90
|
+
key: appStepKey(org, gateKey),
|
|
91
|
+
desired: { mode: 'delete' },
|
|
92
|
+
},
|
|
93
|
+
step: {
|
|
94
|
+
description: `delete ${org.slug}'s '${gateKey}' Cloudflare Access gate: it is no longer declared`,
|
|
95
|
+
destroys: true,
|
|
96
|
+
migrates: false,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
for (const [gateKey, { entry, hostnames }] of declared) {
|
|
101
|
+
const recorded = recordedIds[gateIdRecordKey(org.id, gateKey)];
|
|
102
|
+
if (recorded && recordedIds[gateHostnamesScopeKey(gateKey)] === JSON.stringify(hostnames)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!recorded && missing) {
|
|
106
|
+
blocked = missing;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
steps.push({
|
|
110
|
+
action: {
|
|
111
|
+
system: 'cloudflare',
|
|
112
|
+
kind: 'gate',
|
|
113
|
+
key: appStepKey(org, gateKey),
|
|
114
|
+
desired: { mode: 'apply', entry, hostnames, providerId },
|
|
115
|
+
},
|
|
116
|
+
step: {
|
|
117
|
+
description: recorded
|
|
118
|
+
? `change the hostnames of ${org.slug}'s '${gateKey}' Cloudflare Access gate to ${hostnames.join(', ')}`
|
|
119
|
+
: `put ${hostnames.join(', ')} (${org.slug}'s '${gateKey}' gate) behind Cloudflare Access`,
|
|
120
|
+
destroys: false,
|
|
121
|
+
migrates: false,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return { steps, blocked };
|
|
126
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { appEntry } from '../org.js';
|
|
2
2
|
import { agoraHostnameFor } from './agora.js';
|
|
3
3
|
import { bouleHostnameFor } from './boule.js';
|
|
4
4
|
import { callsHostnameFor, cmsHostnameFor } from './executor.js';
|
|
5
|
+
import { isBehindGate } from './gates.js';
|
|
5
6
|
import { entryHostnames } from './hostnames.js';
|
|
6
7
|
import { loginHostnameFor } from './login-host.js';
|
|
7
8
|
function okExpect() {
|
|
@@ -182,9 +183,7 @@ function hostingEntryTarget(org, entry) {
|
|
|
182
183
|
expect: okExpect(),
|
|
183
184
|
depth: entry.buildPack === 'static' ? 'page' : entry.database ? 'deep' : 'shallow',
|
|
184
185
|
restartSafe: true,
|
|
185
|
-
|
|
186
|
-
// (`org.ts`'s own `HostingEntry.accessEmails` doc comment).
|
|
187
|
-
behindAccess: entry.key === APP_ENTRY_KEY && entry.accessEmails.length > 0,
|
|
186
|
+
behindAccess: isBehindGate(org, hostname),
|
|
188
187
|
};
|
|
189
188
|
}
|
|
190
189
|
/**
|
package/dist/plan/index.d.ts
CHANGED
|
@@ -46,7 +46,8 @@ export type { DnsPort, DnsRecordInput, DnsRecordSummary, EdgePlatformDns } from
|
|
|
46
46
|
export { createEdgeAccess } from './edge-access.js';
|
|
47
47
|
export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, EdgePlatformAccess, } from './edge-access.js';
|
|
48
48
|
export { createEdgeWorkers } from './edge-workers.js';
|
|
49
|
-
export type { EdgePlatformWorkers, EdgeWorkersOptions } from './edge-workers.js';
|
|
49
|
+
export type { EdgePlatformWorkers, EdgeWorkersOptions, PlatformWorkersPort, } from './edge-workers.js';
|
|
50
|
+
export type { PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeDeps, PlatformWorkersFactory, } from './uptime-worker.js';
|
|
50
51
|
export { createEdgeRateLimits } from './edge-ratelimits.js';
|
|
51
52
|
export type { EdgePlatformRateLimits, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, } from './edge-ratelimits.js';
|
|
52
53
|
export { createEdgeZones } from './edge-zones.js';
|
|
@@ -216,12 +216,18 @@ export interface PlatformStepExecutor {
|
|
|
216
216
|
ai?: string;
|
|
217
217
|
archon?: string;
|
|
218
218
|
mail?: string;
|
|
219
|
+
/** #604: see `PlanPlatformOptions.blocked.accessProvider` (`platform-diff.ts`). */
|
|
220
|
+
accessProvider?: string;
|
|
219
221
|
/** #257: see `PlanPlatformOptions.blocked.backupExecutorMissing` (`platform-diff.ts`). */
|
|
220
222
|
backupExecutorMissing?: boolean;
|
|
221
223
|
/** #540: see `PlanPlatformOptions.blocked.hc` (`platform-diff.ts`). */
|
|
222
224
|
hc?: string;
|
|
223
225
|
/** #540: see `PlanPlatformOptions.blocked.heartbeatsExecutorMissing` (`platform-diff.ts`). */
|
|
224
226
|
heartbeatsExecutorMissing?: boolean;
|
|
227
|
+
/** foundry#542: see `PlanPlatformOptions.blocked.uptime` (`platform-diff.ts`). */
|
|
228
|
+
uptime?: string;
|
|
229
|
+
/** foundry#542: see `PlanPlatformOptions.blocked.uptimeSecretsHeld` (`platform-diff.ts`). */
|
|
230
|
+
uptimeSecretsHeld?: string[];
|
|
225
231
|
/** See `PlanPlatformOptions.blocked.archonRunsInsideApp`. */
|
|
226
232
|
archonRunsInsideApp?: boolean;
|
|
227
233
|
/** See `PlanPlatformOptions.blocked.archonDeployRunning`. */
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -98,6 +98,7 @@ const HOSTNAME_STEPS = new Set([
|
|
|
98
98
|
'coolify:database',
|
|
99
99
|
'cloudflare:tunnel',
|
|
100
100
|
'cloudflare:access',
|
|
101
|
+
'cloudflare:gate',
|
|
101
102
|
'jobs:registration',
|
|
102
103
|
// #318 (F8): the route is itself hostname-shaped, validated against the
|
|
103
104
|
// org's own domain by `validateEntryWorker`.
|
|
@@ -1149,6 +1150,7 @@ function destroyTargets(serviceIds, orgId) {
|
|
|
1149
1150
|
'zitadel:login-host:',
|
|
1150
1151
|
'cloudflare:tunnel:',
|
|
1151
1152
|
'cloudflare:access:',
|
|
1153
|
+
'cloudflare:gate:',
|
|
1152
1154
|
// #318: an entry's Worker and its route.
|
|
1153
1155
|
'cloudflare:worker:',
|
|
1154
1156
|
'coolify:database:',
|
|
@@ -1311,7 +1313,8 @@ const STEP_FIELDS = {
|
|
|
1311
1313
|
'coolify:database': 'hosting.database',
|
|
1312
1314
|
'cloudflare:tunnel': 'hosting.tunnel',
|
|
1313
1315
|
'cloudflare:zone': 'hosting.usesDomain',
|
|
1314
|
-
'cloudflare:access': 'hosting.
|
|
1316
|
+
'cloudflare:access': 'hosting.access',
|
|
1317
|
+
'cloudflare:gate': 'hosting.entries.gates',
|
|
1315
1318
|
'cloudflare:ratelimit': 'hosting.usesDomain',
|
|
1316
1319
|
'cloudflare:worker': 'hosting.entries.worker',
|
|
1317
1320
|
};
|
|
@@ -1330,6 +1333,10 @@ function stepField(action) {
|
|
|
1330
1333
|
if (action.system === 'zitadel' && action.kind === 'app') {
|
|
1331
1334
|
return `hosting.issuerApps.${action.key}`;
|
|
1332
1335
|
}
|
|
1336
|
+
// A bypass on a name foundry owns serves the entry gates, not `hosting.access`.
|
|
1337
|
+
if (action.system === 'cloudflare' && action.kind === 'access' && action.desired.ownedBypass) {
|
|
1338
|
+
return 'hosting.entries.gates';
|
|
1339
|
+
}
|
|
1333
1340
|
return STEP_FIELDS[`${action.system}:${action.kind}`] ?? `${action.system}.${action.kind}`;
|
|
1334
1341
|
}
|
|
1335
1342
|
/**
|