@wtfalch/foundry 0.19.0 → 0.20.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 +8 -6
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/plan/agora.d.ts +11 -0
- package/dist/plan/agora.js +13 -0
- package/dist/plan/chat-matrix.d.ts +3 -0
- package/dist/plan/chat-matrix.js +2 -0
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +2 -2
- package/dist/plan/diff.js +26 -5
- package/dist/plan/edge-access.d.ts +68 -0
- package/dist/plan/edge-access.js +123 -0
- package/dist/plan/edge-dns.d.ts +89 -0
- package/dist/plan/edge-dns.js +227 -0
- package/dist/plan/edge-ratelimits.d.ts +54 -0
- package/dist/plan/edge-ratelimits.js +79 -0
- package/dist/plan/edge-tunnels.d.ts +44 -0
- package/dist/plan/edge-tunnels.js +108 -0
- package/dist/plan/edge-workers.d.ts +31 -0
- package/dist/plan/edge-workers.js +165 -0
- package/dist/plan/edge-zones.d.ts +37 -0
- package/dist/plan/edge-zones.js +64 -0
- package/dist/plan/entry-edge-key.d.ts +10 -0
- package/dist/plan/entry-edge-key.js +38 -8
- package/dist/plan/executor.d.ts +124 -143
- package/dist/plan/executor.js +288 -89
- package/dist/plan/index.d.ts +15 -3
- package/dist/plan/index.js +8 -2
- package/dist/plan/plan-apply.js +5 -1
- package/dist/plan/platform-diff.d.ts +9 -0
- package/dist/plan/platform-diff.js +32 -8
- package/dist/plan/platform-providers.d.ts +53 -6
- package/dist/plan/platform-providers.js +87 -44
- package/dist/plan/providers-readers.d.ts +23 -1
- package/dist/plan/providers-readers.js +29 -0
- package/package.json +2 -1
|
@@ -23,12 +23,13 @@ export const DEFAULT_GIT_REF = 'main';
|
|
|
23
23
|
*/
|
|
24
24
|
export const DEFAULT_MAIL_IMAGE_TAG = VERSIONS.stalwart;
|
|
25
25
|
/**
|
|
26
|
-
* Every platform service except `sfu`
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
26
|
+
* Every platform service except `sfu` is enabled, like the other steps: a
|
|
27
|
+
* service whose dependencies the host did not supply is reported blocked,
|
|
28
|
+
* not planned. `sfu` stays off because it needs a held SSH key and host
|
|
29
|
+
* fingerprint, a separate human step (issue 170). `edge` is on because every
|
|
30
|
+
* platform DNS record is written through it (F4.dns); its own Cloudflare API
|
|
31
|
+
* token is a held key (`cloudflare:edge:apiToken`), and until a human holds
|
|
32
|
+
* it the planner reports edge blocked, and auth, mail and archon with it.
|
|
32
33
|
*/
|
|
33
34
|
const SERVICES_ENABLED_BY_DEFAULT = new Set([
|
|
34
35
|
'auth',
|
|
@@ -38,6 +39,7 @@ const SERVICES_ENABLED_BY_DEFAULT = new Set([
|
|
|
38
39
|
'jobs',
|
|
39
40
|
'integrations',
|
|
40
41
|
'archon',
|
|
42
|
+
'edge',
|
|
41
43
|
]);
|
|
42
44
|
/**
|
|
43
45
|
* The owner as `OwnerIdentity`, trimmed. Throws a plain error naming the
|
package/dist/index.d.ts
CHANGED
|
@@ -20,7 +20,9 @@ export type { FoundryStoreDb } from './store/postgres.js';
|
|
|
20
20
|
export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type AppliedVersionRow, type NewAppliedVersionRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
|
|
21
21
|
export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, StepSystem, PlanDetail, PlatformPlanDetail, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
|
|
22
22
|
export { AdoptRefusedError } from './plan/index.js';
|
|
23
|
-
export {
|
|
23
|
+
export { createEdgeAccess, createEdgeDns, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
|
|
24
|
+
export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, DnsPort, EdgePlatformAccess, EdgePlatformDns, EdgePlatformRateLimits, EdgePlatformWorkers, EdgePlatformZones, EdgeZonesOptions, ZonePort, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, EdgeWorkersOptions, EdgePlatformTunnels, TunnelPort, } from './plan/index.js';
|
|
25
|
+
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
24
26
|
export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
|
|
25
27
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
|
|
26
28
|
export type { PlatformOrgClientFactories } from './plan/index.js';
|
|
@@ -29,7 +31,7 @@ export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedu
|
|
|
29
31
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
|
30
32
|
export { HttpJobsSchedulerClient } from './plan/index.js';
|
|
31
33
|
export type { ScopeResolver, RecordedIds } from './plan/index.js';
|
|
32
|
-
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
|
|
34
|
+
export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
|
|
33
35
|
export type { ZitadelListAppsRawApp } from './plan/index.js';
|
|
34
36
|
export { adaptZitadelListApps } from './plan/index.js';
|
|
35
37
|
export type { MailIdentityApp, MailIdentityAudienceCheck } from './plan/index.js';
|
|
@@ -37,7 +39,7 @@ export { checkMailIdentityAudience } from './plan/index.js';
|
|
|
37
39
|
export type { MailAllowlistApp } from './plan/index.js';
|
|
38
40
|
export { checkMailAppsAllowlist } from './plan/index.js';
|
|
39
41
|
export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
|
|
40
|
-
export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformEdgeRuntime, } from './plan/index.js';
|
|
42
|
+
export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './plan/index.js';
|
|
41
43
|
export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './plan/index.js';
|
|
42
44
|
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';
|
|
43
45
|
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
|
@@ -12,12 +12,13 @@ export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, cre
|
|
|
12
12
|
export { createPostgresAuditLog } from './store/audit.js';
|
|
13
13
|
export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
|
|
14
14
|
export { AdoptRefusedError } from './plan/index.js';
|
|
15
|
-
export {
|
|
15
|
+
export { createEdgeAccess, createEdgeDns, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
|
|
16
|
+
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
16
17
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
|
|
17
18
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
|
|
18
19
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
|
19
20
|
export { HttpJobsSchedulerClient } from './plan/index.js';
|
|
20
|
-
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
|
|
21
|
+
export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
|
|
21
22
|
export { adaptZitadelListApps } from './plan/index.js';
|
|
22
23
|
export { checkMailIdentityAudience } from './plan/index.js';
|
|
23
24
|
export { checkMailAppsAllowlist } from './plan/index.js';
|
package/dist/plan/agora.d.ts
CHANGED
|
@@ -151,6 +151,17 @@ export declare const CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY = "coolify_calls_service_
|
|
|
151
151
|
*/
|
|
152
152
|
export declare const CALLS_INTROSPECTION_SCOPE_KEY = "zitadel_calls_introspection_app";
|
|
153
153
|
export declare const CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY = "zitadel_calls_introspection_undelivered";
|
|
154
|
+
/**
|
|
155
|
+
* The introspection API app minted for the org's chat service (once), in the
|
|
156
|
+
* messaging project, and the undelivered key recorded before its secret
|
|
157
|
+
* leaves the executor (platform rule 1). The first doubles as the marker
|
|
158
|
+
* `planChat` reads: recorded after the service's env carries the client. No
|
|
159
|
+
* `:` in either.
|
|
160
|
+
*/
|
|
161
|
+
export declare const CHAT_INTROSPECTION_SCOPE_KEY = "zitadel_chat_introspection_app";
|
|
162
|
+
export declare const CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY = "zitadel_chat_introspection_undelivered";
|
|
163
|
+
/** The chat service's introspection API app name; one per org, exact match only. */
|
|
164
|
+
export declare function chatIntrospectionAppName(org: DeclaredOrg): string;
|
|
154
165
|
/** What the plan hands the calls service step: the two host values the token env needs. */
|
|
155
166
|
export interface CallsTokenHost {
|
|
156
167
|
issuer: string;
|
package/dist/plan/agora.js
CHANGED
|
@@ -163,6 +163,19 @@ export const CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY = 'coolify_calls_service_token_en
|
|
|
163
163
|
*/
|
|
164
164
|
export const CALLS_INTROSPECTION_SCOPE_KEY = 'zitadel_calls_introspection_app';
|
|
165
165
|
export const CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY = 'zitadel_calls_introspection_undelivered';
|
|
166
|
+
/**
|
|
167
|
+
* The introspection API app minted for the org's chat service (once), in the
|
|
168
|
+
* messaging project, and the undelivered key recorded before its secret
|
|
169
|
+
* leaves the executor (platform rule 1). The first doubles as the marker
|
|
170
|
+
* `planChat` reads: recorded after the service's env carries the client. No
|
|
171
|
+
* `:` in either.
|
|
172
|
+
*/
|
|
173
|
+
export const CHAT_INTROSPECTION_SCOPE_KEY = 'zitadel_chat_introspection_app';
|
|
174
|
+
export const CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY = 'zitadel_chat_introspection_undelivered';
|
|
175
|
+
/** The chat service's introspection API app name; one per org, exact match only. */
|
|
176
|
+
export function chatIntrospectionAppName(org) {
|
|
177
|
+
return `chat-introspection-${org.slug}`;
|
|
178
|
+
}
|
|
166
179
|
/**
|
|
167
180
|
* The token env's inputs, or null while any is missing: the host's issuer and
|
|
168
181
|
* messaging project id (`AgoraHostValues`) and the org's recorded ZITADEL org
|
|
@@ -90,6 +90,9 @@ export interface ChatServiceEnvInput {
|
|
|
90
90
|
agoraHost: string;
|
|
91
91
|
masClientId: string;
|
|
92
92
|
masClientSecret: string;
|
|
93
|
+
/** The ZITADEL API app the service introspects Agora's opaque tokens as. */
|
|
94
|
+
introspectionClientId: string;
|
|
95
|
+
introspectionClientSecret: string;
|
|
93
96
|
}
|
|
94
97
|
/** Every variable `chat/apps/chat-service/src/config.ts` requires, plus the internal Synapse address and port. */
|
|
95
98
|
export declare function chatServiceEnvs(input: ChatServiceEnvInput): Array<{
|
package/dist/plan/chat-matrix.js
CHANGED
|
@@ -195,6 +195,8 @@ export function chatServiceEnvs(input) {
|
|
|
195
195
|
{ key: 'MAS_URL', value: upstreams.mas },
|
|
196
196
|
{ key: 'MAS_CLIENT_ID', value: input.masClientId },
|
|
197
197
|
{ key: 'MAS_CLIENT_SECRET', value: input.masClientSecret },
|
|
198
|
+
{ key: 'CHAT_INTROSPECTION_CLIENT_ID', value: input.introspectionClientId },
|
|
199
|
+
{ key: 'CHAT_INTROSPECTION_CLIENT_SECRET', value: input.introspectionClientSecret },
|
|
198
200
|
// DATABASE_URL_OWNER/DATABASE_URL are pushed by executor.ts's
|
|
199
201
|
// pushDatabaseUrl instead, not here (the same split coolifyApp and
|
|
200
202
|
// cmsSite follow, #239) -- that call mints a distinct <dbName>_rt
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { TunnelPort } from './edge-tunnels.js';
|
|
2
|
+
import type { CoolifyPort } from './executor.js';
|
|
2
3
|
/**
|
|
3
4
|
* #197: the pinned `cloudflared` image, the same release tag as
|
|
4
5
|
* `scripts/shared-ingress.mjs`'s and `calls-sfu.ts`'s own
|
|
@@ -24,7 +25,7 @@ export declare function cloudflaredDockerfile(): string;
|
|
|
24
25
|
*/
|
|
25
26
|
export interface EnsureCloudflaredConnectorInput {
|
|
26
27
|
coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'createDockerfileApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy'>;
|
|
27
|
-
|
|
28
|
+
tunnels: Pick<TunnelPort, 'getTunnelToken'>;
|
|
28
29
|
/** The Cloudflare tunnel this connector dials into (already created and configured by the caller). */
|
|
29
30
|
tunnelId: string;
|
|
30
31
|
/** Fixed name, found before created -- so a second apply reuses this app rather than making a second one. */
|
|
@@ -32,7 +32,7 @@ export function cloudflaredDockerfile() {
|
|
|
32
32
|
* with no cause, because a Coolify error can echo the request body.
|
|
33
33
|
*/
|
|
34
34
|
export async function ensureCloudflaredConnector(input) {
|
|
35
|
-
const { coolify,
|
|
35
|
+
const { coolify, tunnels } = input;
|
|
36
36
|
const liveStatus = async (uuid) => {
|
|
37
37
|
try {
|
|
38
38
|
return { found: true, status: (await coolify.getApplication(uuid)).status };
|
|
@@ -82,7 +82,7 @@ export async function ensureCloudflaredConnector(input) {
|
|
|
82
82
|
}
|
|
83
83
|
if (appId !== input.recordedConnectorAppId)
|
|
84
84
|
await input.recordAppId(appId);
|
|
85
|
-
const token = await
|
|
85
|
+
const token = await tunnels.getTunnelToken(input.tunnelId);
|
|
86
86
|
const tokenChanged = justCreated || (await tunnelTokenDiffers(coolify, appId, token));
|
|
87
87
|
if (tokenChanged) {
|
|
88
88
|
try {
|
package/dist/plan/diff.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
2
|
import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
|
|
3
|
-
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
3
|
+
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
4
4
|
import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
5
5
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
6
6
|
import { planEntryAiKeys } from './entry-ai-key.js';
|
|
@@ -214,10 +214,6 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
214
214
|
const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
|
|
215
215
|
if (edgeReservedLabels.step)
|
|
216
216
|
plannedSteps.push(edgeReservedLabels.step);
|
|
217
|
-
// After the apps -- a edge key needs the entry's app recorded, the same
|
|
218
|
-
// dependency `planEntryAiKeys`/`planFilesKeys` have.
|
|
219
|
-
const entryEdgeKeys = planEntryEdgeKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
|
|
220
|
-
plannedSteps.push(...entryEdgeKeys.steps);
|
|
221
217
|
const tunnel = planTunnel(org, byKey, claimed, recordedIds);
|
|
222
218
|
// #238: a tunnel that is being provisioned waits for an active zone; parking
|
|
223
219
|
// one never does.
|
|
@@ -264,6 +260,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
264
260
|
const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null);
|
|
265
261
|
if (boule.step)
|
|
266
262
|
plannedSteps.push(boule.step);
|
|
263
|
+
// After the apps and Boule -- an edge key needs its app recorded or planned,
|
|
264
|
+
// the same dependency `planEntryAiKeys`/`planFilesKeys` have.
|
|
265
|
+
const entryEdgeKeys = planEntryEdgeKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
|
|
266
|
+
plannedSteps.push(...entryEdgeKeys.steps);
|
|
267
267
|
// Boule's own broker entry, after its app: the entry needs the AUTH_APP_KEY
|
|
268
268
|
// that step puts in the app's env.
|
|
269
269
|
const bouleBroker = planBouleBrokerEntry(org, recordedIds, plannedSteps, options.broker ?? false);
|
|
@@ -964,6 +964,27 @@ function planChat(org, byKey, claimed, recordedIds, reading, issuerPlanned) {
|
|
|
964
964
|
blocked: null,
|
|
965
965
|
};
|
|
966
966
|
}
|
|
967
|
+
// A live chat made before its service had an introspection client
|
|
968
|
+
// answers every Agora sign-in with `token_invalid`. The marker is the
|
|
969
|
+
// client's app id, recorded after the service's env carries it.
|
|
970
|
+
if (!recordedIds[CHAT_INTROSPECTION_SCOPE_KEY]) {
|
|
971
|
+
return {
|
|
972
|
+
step: {
|
|
973
|
+
action: {
|
|
974
|
+
system: 'coolify',
|
|
975
|
+
kind: 'chat',
|
|
976
|
+
key: org.id,
|
|
977
|
+
desired: { usesDomain: liveUsesDomain, refreshIntrospection: true },
|
|
978
|
+
},
|
|
979
|
+
step: {
|
|
980
|
+
description: `give ${org.slug}'s chat service its token introspection client and redeploy it`,
|
|
981
|
+
destroys: false,
|
|
982
|
+
migrates: false,
|
|
983
|
+
},
|
|
984
|
+
},
|
|
985
|
+
blocked: null,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
967
988
|
return none;
|
|
968
989
|
}
|
|
969
990
|
const domain = org.domain ?? 'its domain';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { AccessDecision, createEdgeClient } from '@wtfalch/edge';
|
|
2
|
+
import { type WriteSlot } from './edge-dns.js';
|
|
3
|
+
/** The edge SDK's platform Access client: `createEdgeClient({ ... }).platform.access`. */
|
|
4
|
+
export type EdgePlatformAccess = ReturnType<typeof createEdgeClient>['platform']['access'];
|
|
5
|
+
export type { AccessDecision };
|
|
6
|
+
/** One Access rule in Cloudflare's own shape: `{ email: { email } }`, `{ everyone: {} }`, `{ service_token: { token_id } }`. */
|
|
7
|
+
export type AccessPolicyRule = Record<string, unknown>;
|
|
8
|
+
export interface AccessAppSummary {
|
|
9
|
+
/** Cloudflare's app id: what every edge Access call takes. */
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
domain: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Foundry's Cloudflare Access calls. Every Access call in foundry goes through
|
|
16
|
+
* this; the one implementation is {@link createEdgeAccess}, which speaks to the
|
|
17
|
+
* edge service with the platform credential.
|
|
18
|
+
*/
|
|
19
|
+
export interface AccessPort {
|
|
20
|
+
/** Every live Access app foundry may use: the platform's own, and ones nobody adopted yet. */
|
|
21
|
+
listAccessApps(): Promise<AccessAppSummary[]>;
|
|
22
|
+
createAccessApp(app: {
|
|
23
|
+
name: string;
|
|
24
|
+
domain: string;
|
|
25
|
+
type: 'self_hosted';
|
|
26
|
+
}): Promise<{
|
|
27
|
+
id: string;
|
|
28
|
+
}>;
|
|
29
|
+
listAccessPolicies(appId: string): Promise<Array<{
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
include?: AccessPolicyRule[];
|
|
33
|
+
}>>;
|
|
34
|
+
createAccessPolicy(appId: string, policy: {
|
|
35
|
+
name: string;
|
|
36
|
+
decision: AccessDecision;
|
|
37
|
+
include: AccessPolicyRule[];
|
|
38
|
+
}): Promise<{
|
|
39
|
+
id: string;
|
|
40
|
+
}>;
|
|
41
|
+
/**
|
|
42
|
+
* A service token's id by exact name -- the token itself is a live
|
|
43
|
+
* Cloudflare resource foundry never mints and whose secret it never reads.
|
|
44
|
+
* `undefined` when no token has that name; throws when more than one does.
|
|
45
|
+
*/
|
|
46
|
+
findServiceTokenByName(name: string): Promise<{
|
|
47
|
+
id: string;
|
|
48
|
+
} | undefined>;
|
|
49
|
+
deleteAccessApp(appId: string): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Edge answers a policy, delete or create call for a live Access app with no
|
|
53
|
+
* edge row `not_found` or 409 `exists` ("create or adopt it first"). So the
|
|
54
|
+
* adapter adopts an app the moment it is about to be used: an app it saw in a
|
|
55
|
+
* list (or that a create refused with `exists`) whose owner edge reported as
|
|
56
|
+
* `null` is remembered, and adopted just before the first call on it. An app
|
|
57
|
+
* foundry recorded in an earlier apply and now names by id only is looked up
|
|
58
|
+
* on `not_found`, adopted if unowned, and the call retried once, so a destroy
|
|
59
|
+
* deletes it instead of finding "nothing there". Adopt is idempotent and the
|
|
60
|
+
* list reports the owner afterwards, so nothing is recorded in the plan. An
|
|
61
|
+
* app with an owner is never adopted or changed. An adopt that edge skips
|
|
62
|
+
* (`taken`, `unsupported_type`) is an error. A `rate_limited` answer is
|
|
63
|
+
* retried with edge's hint, up to 5 tries.
|
|
64
|
+
*/
|
|
65
|
+
export declare function createEdgeAccess(client: EdgePlatformAccess, options?: {
|
|
66
|
+
sleep?: (ms: number) => Promise<void>;
|
|
67
|
+
writeSlot?: WriteSlot;
|
|
68
|
+
}): AccessPort;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { EdgeApiError } from '@wtfalch/edge';
|
|
2
|
+
import { EDGE_DNS_WRITE_CONCURRENCY, limiter, rateLimitRetrier, } from './edge-dns.js';
|
|
3
|
+
const isNotFound = (error) => error instanceof EdgeApiError && error.code === 'not_found';
|
|
4
|
+
/** Edge's 409 for a live app on the domain with no edge row. */
|
|
5
|
+
const isExists = (error) => error instanceof EdgeApiError && error.code === 'conflict' && error.reason === 'exists';
|
|
6
|
+
/**
|
|
7
|
+
* Edge answers a policy, delete or create call for a live Access app with no
|
|
8
|
+
* edge row `not_found` or 409 `exists` ("create or adopt it first"). So the
|
|
9
|
+
* adapter adopts an app the moment it is about to be used: an app it saw in a
|
|
10
|
+
* list (or that a create refused with `exists`) whose owner edge reported as
|
|
11
|
+
* `null` is remembered, and adopted just before the first call on it. An app
|
|
12
|
+
* foundry recorded in an earlier apply and now names by id only is looked up
|
|
13
|
+
* on `not_found`, adopted if unowned, and the call retried once, so a destroy
|
|
14
|
+
* deletes it instead of finding "nothing there". Adopt is idempotent and the
|
|
15
|
+
* list reports the owner afterwards, so nothing is recorded in the plan. An
|
|
16
|
+
* app with an owner is never adopted or changed. An adopt that edge skips
|
|
17
|
+
* (`taken`, `unsupported_type`) is an error. A `rate_limited` answer is
|
|
18
|
+
* retried with edge's hint, up to 5 tries.
|
|
19
|
+
*/
|
|
20
|
+
export function createEdgeAccess(client, options = {}) {
|
|
21
|
+
const retrying = rateLimitRetrier(options.sleep);
|
|
22
|
+
const access = {
|
|
23
|
+
list: (filter) => retrying(() => client.list(filter)),
|
|
24
|
+
create: (input) => retrying(() => client.create(input)),
|
|
25
|
+
remove: (id) => retrying(() => client.remove(id)),
|
|
26
|
+
listPolicies: (appId) => retrying(() => client.listPolicies(appId)),
|
|
27
|
+
createPolicy: (appId, input) => retrying(() => client.createPolicy(appId, input)),
|
|
28
|
+
findServiceToken: (name) => retrying(() => client.findServiceToken(name)),
|
|
29
|
+
adopt: (input) => retrying(() => client.adopt(input)),
|
|
30
|
+
};
|
|
31
|
+
const writeSlot = options.writeSlot ?? limiter(EDGE_DNS_WRITE_CONCURRENCY);
|
|
32
|
+
/** app id -> a seen live app with no edge row yet. */
|
|
33
|
+
const unadopted = new Map();
|
|
34
|
+
const remember = (views) => {
|
|
35
|
+
for (const view of views) {
|
|
36
|
+
if (view.owner === null)
|
|
37
|
+
unadopted.set(view.id, view);
|
|
38
|
+
else
|
|
39
|
+
unadopted.delete(view.id);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const adopt = async (view) => {
|
|
43
|
+
const result = await writeSlot(() => access.adopt({ apps: [{ name: view.name, domain: view.domain, id: view.id }] }));
|
|
44
|
+
const skipped = result.skipped[0];
|
|
45
|
+
if (skipped) {
|
|
46
|
+
throw new Error(`edge access: ${view.name} (${view.domain}) cannot be adopted (${skipped.reason}); remove or fix it by hand`);
|
|
47
|
+
}
|
|
48
|
+
if (result.notFound.length > 0) {
|
|
49
|
+
throw new EdgeApiError(404, 'not_found', `edge access: ${view.name} is no longer live`);
|
|
50
|
+
}
|
|
51
|
+
unadopted.delete(view.id);
|
|
52
|
+
};
|
|
53
|
+
/** Runs a call on an app; adopts it first when seen unowned, and once more on `not_found`. */
|
|
54
|
+
const onApp = async (appId, call) => {
|
|
55
|
+
const seen = unadopted.get(appId);
|
|
56
|
+
if (seen)
|
|
57
|
+
await adopt(seen);
|
|
58
|
+
try {
|
|
59
|
+
return await call();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (!isNotFound(error))
|
|
63
|
+
throw error;
|
|
64
|
+
const live = (await access.list()).apps;
|
|
65
|
+
remember(live);
|
|
66
|
+
const view = live.find((a) => a.id === appId);
|
|
67
|
+
if (!view || view.owner !== null)
|
|
68
|
+
throw error;
|
|
69
|
+
await adopt(view);
|
|
70
|
+
return call();
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const listAccessApps = async () => {
|
|
74
|
+
const { apps } = await access.list();
|
|
75
|
+
remember(apps);
|
|
76
|
+
return apps.map((a) => ({ id: a.id, name: a.name, domain: a.domain }));
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
listAccessApps,
|
|
80
|
+
async createAccessApp(app) {
|
|
81
|
+
try {
|
|
82
|
+
const created = await writeSlot(() => access.create({ name: app.name, domain: app.domain, type: app.type }));
|
|
83
|
+
return { id: created.id };
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
// A live app holds the domain with no edge row. The one of this name
|
|
87
|
+
// and domain is foundry's own from before edge owned Access: use it,
|
|
88
|
+
// adopting it before its first policy call. Any other app is refused.
|
|
89
|
+
if (!isExists(error))
|
|
90
|
+
throw error;
|
|
91
|
+
const { apps } = await access.list({ domain: app.domain });
|
|
92
|
+
remember(apps);
|
|
93
|
+
const same = apps.find((a) => a.name === app.name && a.owner === null);
|
|
94
|
+
if (!same)
|
|
95
|
+
throw error;
|
|
96
|
+
return { id: same.id };
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
async listAccessPolicies(appId) {
|
|
100
|
+
const { policies } = await onApp(appId, () => access.listPolicies(appId));
|
|
101
|
+
return policies.map((p) => ({ id: p.id, name: p.name, include: p.include }));
|
|
102
|
+
},
|
|
103
|
+
async createAccessPolicy(appId, policy) {
|
|
104
|
+
const created = await onApp(appId, () => writeSlot(() => access.createPolicy(appId, policy)));
|
|
105
|
+
return { id: created.id };
|
|
106
|
+
},
|
|
107
|
+
async findServiceTokenByName(name) {
|
|
108
|
+
try {
|
|
109
|
+
const { token } = await access.findServiceToken(name);
|
|
110
|
+
return token ? { id: token.id } : undefined;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof EdgeApiError && error.code === 'conflict') {
|
|
114
|
+
throw new Error(`edge access: more than one Cloudflare Access service token is named '${name}'; rename or remove the extras`);
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
async deleteAccessApp(appId) {
|
|
120
|
+
await onApp(appId, () => writeSlot(() => access.remove(appId)));
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { createEdgeClient } from '@wtfalch/edge';
|
|
2
|
+
/** The edge SDK's platform DNS client: `createEdgeClient({ ... }).platform.dns`. */
|
|
3
|
+
export type EdgePlatformDns = ReturnType<typeof createEdgeClient>['platform']['dns'];
|
|
4
|
+
export interface DnsRecordSummary {
|
|
5
|
+
id: string;
|
|
6
|
+
type: string;
|
|
7
|
+
content: string;
|
|
8
|
+
name: string;
|
|
9
|
+
}
|
|
10
|
+
export interface DnsRecordInput {
|
|
11
|
+
type: string;
|
|
12
|
+
name: string;
|
|
13
|
+
content: string;
|
|
14
|
+
proxied?: boolean;
|
|
15
|
+
ttl?: number;
|
|
16
|
+
/** Required for an `MX` record. */
|
|
17
|
+
priority?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Foundry's DNS calls. Every record call in foundry goes through this; the one
|
|
21
|
+
* implementation is {@link createEdgeDns}, which speaks to the edge service
|
|
22
|
+
* with the platform credential. `zone` is the zone's own domain (what
|
|
23
|
+
* `ZonePort.findZoneByApex` returns as `name`), not a Cloudflare zone
|
|
24
|
+
* id: edge addresses a zone by its domain.
|
|
25
|
+
*/
|
|
26
|
+
export interface DnsPort {
|
|
27
|
+
/** An exact name + type match, so a stale record of a different type is never mistaken for a match. */
|
|
28
|
+
findDnsRecord(zone: string, name: string, type?: string): Promise<DnsRecordSummary[]>;
|
|
29
|
+
/**
|
|
30
|
+
* Every record of a type in the zone, for a domain recorded before
|
|
31
|
+
* `CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY` existed and so with no
|
|
32
|
+
* hostname list to look up instead.
|
|
33
|
+
*/
|
|
34
|
+
listDnsRecords(zone: string, opts?: {
|
|
35
|
+
type?: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
}): Promise<DnsRecordSummary[]>;
|
|
38
|
+
createDnsRecord(zone: string, rec: DnsRecordInput): Promise<{
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
content: string;
|
|
42
|
+
}>;
|
|
43
|
+
updateDnsRecord(zone: string, recordId: string, rec: DnsRecordInput): Promise<{
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
content: string;
|
|
47
|
+
}>;
|
|
48
|
+
deleteDnsRecord(zone: string, recordId: string): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
/** Edge answers its platform writes from one shared slot of 4 (company-less writes). */
|
|
51
|
+
export declare const EDGE_DNS_WRITE_CONCURRENCY = 4;
|
|
52
|
+
/**
|
|
53
|
+
* Edge lists a TXT record raw, quoted (`"v=spf1 ..."`, long values in several
|
|
54
|
+
* quoted chunks), but returns it unquoted from a create or update. Everything
|
|
55
|
+
* foundry compares sees the unquoted form.
|
|
56
|
+
*/
|
|
57
|
+
export declare function unquoteTxt(content: string): string;
|
|
58
|
+
export type WriteSlot = <T>(task: () => Promise<T>) => Promise<T>;
|
|
59
|
+
export declare function limiter(max: number): WriteSlot;
|
|
60
|
+
/**
|
|
61
|
+
* Edge's company-less platform calls share one per-org slot of 4 and answer a
|
|
62
|
+
* full slot with `rate_limited` and a retry hint, without queueing. The SDK
|
|
63
|
+
* does not retry, so every call here waits the hint out, up to this many tries.
|
|
64
|
+
*/
|
|
65
|
+
export declare const EDGE_DNS_RATE_LIMIT_ATTEMPTS = 5;
|
|
66
|
+
/**
|
|
67
|
+
* Retries a call edge answered `rate_limited`, waiting out its hint (capped at
|
|
68
|
+
* 30 s), up to {@link EDGE_DNS_RATE_LIMIT_ATTEMPTS} tries. Shared by every
|
|
69
|
+
* edge adapter, since all platform calls draw on the one slot.
|
|
70
|
+
*/
|
|
71
|
+
export declare function rateLimitRetrier(sleepOption?: (ms: number) => Promise<void>): <T>(task: () => Promise<T>) => Promise<T>;
|
|
72
|
+
/**
|
|
73
|
+
* Edge answers an update or delete of a live record with no edge row
|
|
74
|
+
* `not_found` ("adopt it first"). So the adapter adopts a record the moment it
|
|
75
|
+
* is about to be changed or removed: every record it has returned that the
|
|
76
|
+
* list reported with `owner: null` is remembered, and adopted just before the
|
|
77
|
+
* first write that names it. Adopt is idempotent and the list reports the
|
|
78
|
+
* owner once adopted, so nothing is recorded in the plan and a later apply
|
|
79
|
+
* adopts nothing. A record foundry only reads (a foreign record it refuses to
|
|
80
|
+
* overwrite) is never adopted. An `unsupported_type`, `taken` or `organisation`
|
|
81
|
+
* skip is an error; an `unclaimed` record is left alone. A company's own record
|
|
82
|
+
* (edge answers `forbidden`) is never changed: the error names zone, name and
|
|
83
|
+
* type. A `rate_limited` answer is retried with edge's hint, up to 5 tries.
|
|
84
|
+
*/
|
|
85
|
+
export declare function createEdgeDns(client: EdgePlatformDns, options?: {
|
|
86
|
+
sleep?: (ms: number) => Promise<void>;
|
|
87
|
+
/** Share one slot of {@link EDGE_DNS_WRITE_CONCURRENCY} with the tunnel adapter: edge counts both. */
|
|
88
|
+
writeSlot?: WriteSlot;
|
|
89
|
+
}): DnsPort;
|