@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.
@@ -694,6 +694,18 @@ export declare const PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF = "cloudflare:edge:apiTo
694
694
  * reports it and never mints again. Neither key holds the secret itself.
695
695
  */
696
696
  export declare const PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY = "edge_platform_key_undelivered";
697
+ /**
698
+ * #604: the ZITADEL client Cloudflare Access signs people in through. Its
699
+ * secret exists once, at creation, and goes to edge's `ensureProvider`. The
700
+ * client's app id is recorded here and persisted before the secret leaves;
701
+ * the guard, `ACCESS_PROVIDER_RECORD_KEY` (`gates.ts`), is recorded only
702
+ * after edge returned the provider's id. An undelivered id with no guard
703
+ * means the secret is lost: the planner reports it and never mints again.
704
+ */
705
+ export declare const PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY = "access_provider_client_undelivered";
706
+ /** The sign-in hostname and the Access team address the provider was applied with. */
707
+ export declare const PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY = "access_provider_applied_hostname";
708
+ export declare const PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY = "access_provider_applied_auth_domain";
697
709
  export declare const PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY = "edge_platform_key_set";
698
710
  /**
699
711
  * #478: the hostname and `gitRef` the last successful names run applied.
@@ -810,6 +822,79 @@ export declare function heartbeatCheckUrlRef(name: string): string;
810
822
  * bootstrapping at all.
811
823
  */
812
824
  export declare const PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY = "heartbeats_applied_desired";
825
+ /**
826
+ * foundry#542: the uptime Worker, Archon's outside prober, deployed from the
827
+ * built script Archon publishes with each release. The step is
828
+ * `cloudflare:worker` keyed {@link PLATFORM_UPTIME_KEY}; `runPlatformSteps`
829
+ * records the script name under `cloudflare:worker:platform:uptime` once it
830
+ * succeeds, which is what stops `planUptime` planning it again.
831
+ */
832
+ export declare const PLATFORM_UPTIME_KEY = "platform:uptime";
833
+ /** The Worker script's name at Cloudflare. */
834
+ export declare const PLATFORM_UPTIME_SCRIPT_NAME = "platform-uptime";
835
+ /** The Workers KV namespace title behind the Worker's `UPTIME_KV` binding. */
836
+ export declare const PLATFORM_UPTIME_KV_TITLE = "platform-uptime";
837
+ /**
838
+ * foundry#542: the held-key refs of the Worker's secrets. `UPTIME_TOKEN` is
839
+ * minted by the step (the token Archon's `UPTIME_TOKEN` env var also takes);
840
+ * the Access pair is minted through edge by the step, and the other two are held by a person. A missing one is reported as
841
+ * a blocked item, never a throw.
842
+ */
843
+ export declare const PLATFORM_UPTIME_TOKEN_REF = "uptime:worker:token";
844
+ export declare const PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF = "uptime:worker:access-client-id";
845
+ export declare const PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF = "uptime:worker:access-client-secret";
846
+ export declare const PLATFORM_UPTIME_CHAT_TOKEN_REF = "uptime:worker:chat-token";
847
+ export declare const PLATFORM_UPTIME_MAIL_KEY_REF = "uptime:worker:mail-key";
848
+ /**
849
+ * Worker secret name to the held-key ref it is read from, for the secrets a
850
+ * person holds. `UPTIME_TOKEN` and the two `CF_ACCESS_CLIENT_*` secrets are
851
+ * minted by the step and always set, so they are not listed.
852
+ */
853
+ export declare const PLATFORM_UPTIME_OPTIONAL_SECRETS: Readonly<Record<string, string>>;
854
+ /**
855
+ * foundry#542: scope keys (colon-free), the {@link PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY}
856
+ * pattern once. The undelivered id is recorded and persisted the moment the
857
+ * token is generated; the guard is recorded as soon as the value is durably
858
+ * held, before anything is sent to the Worker or Archon. Setting the value on
859
+ * the Worker and on Archon is repeated from the held value on every apply
860
+ * that needs it. Neither key holds the token; the id is the Worker secret's
861
+ * version label.
862
+ */
863
+ export declare const PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY = "uptime_token_undelivered";
864
+ export declare const PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY = "uptime_token_id";
865
+ /** The token id Archon's env was last written with; `planArchon` plans again when it differs from {@link PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY}. */
866
+ export declare const PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY = "archon_uptime_token_synced";
867
+ /** The tag, sha256 and Archon hostname the last successful run applied; a change plans the step again and mints nothing. */
868
+ export declare const PLATFORM_UPTIME_APPLIED_TAG_SCOPE_KEY = "uptime_applied_tag";
869
+ export declare const PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY = "uptime_applied_sha256";
870
+ export declare const PLATFORM_UPTIME_APPLIED_HOSTNAME_SCOPE_KEY = "uptime_applied_hostname";
871
+ /** The secret names (names only) the last run set, sorted and comma-joined: a newly held secret plans the step again. */
872
+ export declare const PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY = "uptime_applied_secrets";
873
+ /** A digest of the plain text variables the last run uploaded (they are not secrets): a changed one plans the step again. */
874
+ export declare const PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY = "uptime_applied_vars";
875
+ /** `'1'` once the step removed the Worker because the platform no longer enables it; cleared when it deploys again. */
876
+ export declare const PLATFORM_UPTIME_REMOVED_SCOPE_KEY = "uptime_removed";
877
+ /** The digest of the Worker's plain text variables that are set; empty when none is. */
878
+ export declare function uptimeVarsDigest(vars?: Readonly<Record<string, string | undefined>>): string;
879
+ /** The Worker secret names the step sets: the three it mints, and each held one. */
880
+ export declare function uptimeSecretNames(held: readonly string[]): string;
881
+ /** The Access service token's name at Cloudflare; edge answers `conflict` when a token of this name exists. */
882
+ export declare const PLATFORM_UPTIME_ACCESS_TOKEN_NAME = "platform-uptime";
883
+ /**
884
+ * The Access service token pair, minted through edge once: the same
885
+ * undelivered-then-guard pattern as the Worker token above. The undelivered
886
+ * id is recorded and persisted the moment edge returns the pair; the guard is
887
+ * recorded once both values are held. A pair that may be lost (the undelivered
888
+ * id without its guard, or the guard with nothing held) is repaired by the
889
+ * next apply: it removes that token through edge and mints again. Both are
890
+ * ids, never a value. Archon's step reads the guard to allow the token.
891
+ */
892
+ export declare const PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY = "uptime_access_undelivered";
893
+ export declare const PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY = "uptime_access_token_id";
894
+ /** Why the pair cannot be minted: a token of the name exists and foundry has no id recorded for it. */
895
+ export declare function uptimeAccessTokenConflict(): string;
896
+ /** Whether anything of the uptime Worker is recorded that disabling it must remove. */
897
+ export declare function uptimeRecorded(recordedIds: Readonly<Record<string, string>>): boolean;
813
898
  /**
814
899
  * Options for {@link planPlatform}, replacing four positional arguments that
815
900
  * shifted on every merge that added a platform service (#186, #187, #188) --
@@ -850,6 +935,8 @@ export interface PlanPlatformOptions {
850
935
  mail?: string;
851
936
  /** #478: why the edge host is blocked, or absent when it is not. */
852
937
  edge?: string;
938
+ /** #604: why the Access sign-in provider cannot be made yet (no dependencies, or edge cannot confirm Access is ready), or absent. */
939
+ accessProvider?: string;
853
940
  /**
854
941
  * #257: true once a service's database exists but the host built the
855
942
  * platform executor with no backup dependencies -- every such service's
@@ -884,6 +971,21 @@ export interface PlanPlatformOptions {
884
971
  * Not a block.
885
972
  */
886
973
  archonRunsInsideApp?: boolean;
974
+ /** foundry#542: why the uptime Worker is blocked, or absent when it is not. */
975
+ uptime?: string;
976
+ /** foundry#542: the optional Worker secret names (`CF_ACCESS_CLIENT_ID`, ...) that are held now; names only. */
977
+ uptimeSecretsHeld?: string[];
978
+ /**
979
+ * foundry#542: what the host reads back for the uptime Worker: whether the
980
+ * Worker token and the Access pair are held, and the digest of its plain
981
+ * text variables ({@link uptimeVarsDigest}). Nothing held plans the step
982
+ * again; it repairs that, it is not a block.
983
+ */
984
+ uptimeHeld?: {
985
+ token: boolean;
986
+ access: boolean;
987
+ vars: string;
988
+ };
887
989
  /** Why Archon's step must wait: the deployment it triggered is still running. Blocks even an applied step. */
888
990
  archonDeployRunning?: string;
889
991
  };
@@ -1,6 +1,8 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { VERSIONS } from '../bootstrap/versions.js';
2
3
  import { APP_ENTRY_KEY } from '../org.js';
3
4
  import { VERSION_CHANGE_KIND, } from '../plan.js';
5
+ import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
4
6
  import { legacyAppHostname } from './hostnames.js';
5
7
  import { withStepSystem } from './step-system.js';
6
8
  /**
@@ -771,6 +773,18 @@ export const PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF = 'cloudflare:edge:apiToken';
771
773
  * reports it and never mints again. Neither key holds the secret itself.
772
774
  */
773
775
  export const PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY = 'edge_platform_key_undelivered';
776
+ /**
777
+ * #604: the ZITADEL client Cloudflare Access signs people in through. Its
778
+ * secret exists once, at creation, and goes to edge's `ensureProvider`. The
779
+ * client's app id is recorded here and persisted before the secret leaves;
780
+ * the guard, `ACCESS_PROVIDER_RECORD_KEY` (`gates.ts`), is recorded only
781
+ * after edge returned the provider's id. An undelivered id with no guard
782
+ * means the secret is lost: the planner reports it and never mints again.
783
+ */
784
+ export const PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY = 'access_provider_client_undelivered';
785
+ /** The sign-in hostname and the Access team address the provider was applied with. */
786
+ export const PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY = 'access_provider_applied_hostname';
787
+ export const PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY = 'access_provider_applied_auth_domain';
774
788
  export const PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY = 'edge_platform_key_set';
775
789
  /**
776
790
  * #478: the hostname and `gitRef` the last successful names run applied.
@@ -907,6 +921,100 @@ export function heartbeatCheckUrlRef(name) {
907
921
  * bootstrapping at all.
908
922
  */
909
923
  export const PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY = 'heartbeats_applied_desired';
924
+ /**
925
+ * foundry#542: the uptime Worker, Archon's outside prober, deployed from the
926
+ * built script Archon publishes with each release. The step is
927
+ * `cloudflare:worker` keyed {@link PLATFORM_UPTIME_KEY}; `runPlatformSteps`
928
+ * records the script name under `cloudflare:worker:platform:uptime` once it
929
+ * succeeds, which is what stops `planUptime` planning it again.
930
+ */
931
+ export const PLATFORM_UPTIME_KEY = 'platform:uptime';
932
+ /** The Worker script's name at Cloudflare. */
933
+ export const PLATFORM_UPTIME_SCRIPT_NAME = 'platform-uptime';
934
+ /** The Workers KV namespace title behind the Worker's `UPTIME_KV` binding. */
935
+ export const PLATFORM_UPTIME_KV_TITLE = 'platform-uptime';
936
+ /**
937
+ * foundry#542: the held-key refs of the Worker's secrets. `UPTIME_TOKEN` is
938
+ * minted by the step (the token Archon's `UPTIME_TOKEN` env var also takes);
939
+ * the Access pair is minted through edge by the step, and the other two are held by a person. A missing one is reported as
940
+ * a blocked item, never a throw.
941
+ */
942
+ export const PLATFORM_UPTIME_TOKEN_REF = 'uptime:worker:token';
943
+ export const PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF = 'uptime:worker:access-client-id';
944
+ export const PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF = 'uptime:worker:access-client-secret';
945
+ export const PLATFORM_UPTIME_CHAT_TOKEN_REF = 'uptime:worker:chat-token';
946
+ export const PLATFORM_UPTIME_MAIL_KEY_REF = 'uptime:worker:mail-key';
947
+ /**
948
+ * Worker secret name to the held-key ref it is read from, for the secrets a
949
+ * person holds. `UPTIME_TOKEN` and the two `CF_ACCESS_CLIENT_*` secrets are
950
+ * minted by the step and always set, so they are not listed.
951
+ */
952
+ export const PLATFORM_UPTIME_OPTIONAL_SECRETS = {
953
+ CHAT_ACCESS_TOKEN: PLATFORM_UPTIME_CHAT_TOKEN_REF,
954
+ MAIL_API_KEY: PLATFORM_UPTIME_MAIL_KEY_REF,
955
+ };
956
+ /**
957
+ * foundry#542: scope keys (colon-free), the {@link PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY}
958
+ * pattern once. The undelivered id is recorded and persisted the moment the
959
+ * token is generated; the guard is recorded as soon as the value is durably
960
+ * held, before anything is sent to the Worker or Archon. Setting the value on
961
+ * the Worker and on Archon is repeated from the held value on every apply
962
+ * that needs it. Neither key holds the token; the id is the Worker secret's
963
+ * version label.
964
+ */
965
+ export const PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY = 'uptime_token_undelivered';
966
+ export const PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY = 'uptime_token_id';
967
+ /** The token id Archon's env was last written with; `planArchon` plans again when it differs from {@link PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY}. */
968
+ export const PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY = 'archon_uptime_token_synced';
969
+ /** The tag, sha256 and Archon hostname the last successful run applied; a change plans the step again and mints nothing. */
970
+ export const PLATFORM_UPTIME_APPLIED_TAG_SCOPE_KEY = 'uptime_applied_tag';
971
+ export const PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY = 'uptime_applied_sha256';
972
+ export const PLATFORM_UPTIME_APPLIED_HOSTNAME_SCOPE_KEY = 'uptime_applied_hostname';
973
+ /** The secret names (names only) the last run set, sorted and comma-joined: a newly held secret plans the step again. */
974
+ export const PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY = 'uptime_applied_secrets';
975
+ /** A digest of the plain text variables the last run uploaded (they are not secrets): a changed one plans the step again. */
976
+ export const PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY = 'uptime_applied_vars';
977
+ /** `'1'` once the step removed the Worker because the platform no longer enables it; cleared when it deploys again. */
978
+ export const PLATFORM_UPTIME_REMOVED_SCOPE_KEY = 'uptime_removed';
979
+ /** The digest of the Worker's plain text variables that are set; empty when none is. */
980
+ export function uptimeVarsDigest(vars = {}) {
981
+ const set = Object.entries(vars)
982
+ .filter(([, value]) => Boolean(value))
983
+ .sort(([a], [b]) => a.localeCompare(b));
984
+ if (set.length === 0)
985
+ return '';
986
+ return createHash('sha256').update(JSON.stringify(set)).digest('hex').slice(0, 16);
987
+ }
988
+ /** The Worker secret names the step sets: the three it mints, and each held one. */
989
+ export function uptimeSecretNames(held) {
990
+ return ['UPTIME_TOKEN', 'CF_ACCESS_CLIENT_ID', 'CF_ACCESS_CLIENT_SECRET', ...held]
991
+ .sort()
992
+ .join(',');
993
+ }
994
+ /** The Access service token's name at Cloudflare; edge answers `conflict` when a token of this name exists. */
995
+ export const PLATFORM_UPTIME_ACCESS_TOKEN_NAME = 'platform-uptime';
996
+ /**
997
+ * The Access service token pair, minted through edge once: the same
998
+ * undelivered-then-guard pattern as the Worker token above. The undelivered
999
+ * id is recorded and persisted the moment edge returns the pair; the guard is
1000
+ * recorded once both values are held. A pair that may be lost (the undelivered
1001
+ * id without its guard, or the guard with nothing held) is repaired by the
1002
+ * next apply: it removes that token through edge and mints again. Both are
1003
+ * ids, never a value. Archon's step reads the guard to allow the token.
1004
+ */
1005
+ export const PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY = 'uptime_access_undelivered';
1006
+ export const PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY = 'uptime_access_token_id';
1007
+ /** Why the pair cannot be minted: a token of the name exists and foundry has no id recorded for it. */
1008
+ export function uptimeAccessTokenConflict() {
1009
+ return `the uptime Worker is blocked, not planned: a Cloudflare Access service token named '${PLATFORM_UPTIME_ACCESS_TOKEN_NAME}' already exists and foundry did not make it; remove that token in Cloudflare Zero Trust (Access, Service Auth), then the next apply mints one through edge`;
1010
+ }
1011
+ /** Whether anything of the uptime Worker is recorded that disabling it must remove. */
1012
+ export function uptimeRecorded(recordedIds) {
1013
+ return ((Boolean(recordedIds[resourceKey('cloudflare', 'worker', PLATFORM_UPTIME_KEY)]) &&
1014
+ !recordedIds[PLATFORM_UPTIME_REMOVED_SCOPE_KEY]) ||
1015
+ Boolean(recordedIds[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY]) ||
1016
+ Boolean(recordedIds[PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY]));
1017
+ }
910
1018
  /**
911
1019
  * #167: the platform's own diff engine, mirroring `diff.ts`'s
912
1020
  * `computePlanDetail` for the one shared {@link DeclaredPlatform} record
@@ -999,6 +1107,11 @@ export function planPlatform(platform, live, options = {}) {
999
1107
  const ai = planAi(platform, recordedIds, blocked.ai);
1000
1108
  if (ai.step)
1001
1109
  plannedSteps.push(ai.step);
1110
+ // #604: after edge and sign-in, whose applied ids it reads; before Archon,
1111
+ // whose redeploy can end the run.
1112
+ const accessProvider = planAccessProvider(platform, recordedIds, blocked.accessProvider);
1113
+ if (accessProvider.step)
1114
+ plannedSteps.push(accessProvider.step);
1002
1115
  // #175: last, on purpose. Archon's sign-in client lives in the sign-in
1003
1116
  // `planAuth` builds, and its env reads what every other service's step
1004
1117
  // records or delivers, so in one apply it runs after all of them.
@@ -1017,6 +1130,11 @@ export function planPlatform(platform, live, options = {}) {
1017
1130
  const heartbeats = planHeartbeats(platform, recordedIds, hcReady, blocked.heartbeatsExecutorMissing ?? false, blocked.heartbeatsApiKeyMissing ?? false);
1018
1131
  if (heartbeats.step)
1019
1132
  plannedSteps.push(heartbeats.step);
1133
+ // foundry#542: after Archon is applied (never merely planned): the Worker
1134
+ // probes Archon, and Archon's env takes the token this step mints.
1135
+ const uptime = planUptime(platform, recordedIds, blocked.uptime, blocked.uptimeSecretsHeld ?? [], Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY)]), blocked.uptimeHeld);
1136
+ if (uptime.step)
1137
+ plannedSteps.push(uptime.step);
1020
1138
  if (archon.step && blocked.archonRunsInsideApp)
1021
1139
  plannedSteps.push(archon.step);
1022
1140
  // #251: one line per service whose recorded applied ref no longer matches
@@ -1081,6 +1199,9 @@ export function planPlatform(platform, live, options = {}) {
1081
1199
  unmanaged.push({ description: ai.blocked, key: 'ai' });
1082
1200
  if (edge.blocked)
1083
1201
  unmanaged.push({ description: edge.blocked, key: 'edge' });
1202
+ if (accessProvider.blocked) {
1203
+ unmanaged.push({ description: accessProvider.blocked, key: 'access-provider' });
1204
+ }
1084
1205
  if (hc.blocked)
1085
1206
  unmanaged.push({ description: hc.blocked, key: 'hc' });
1086
1207
  if (archon.blocked)
@@ -1099,6 +1220,8 @@ export function planPlatform(platform, live, options = {}) {
1099
1220
  unmanaged.push(...backups.blocked);
1100
1221
  if (heartbeats.blocked)
1101
1222
  unmanaged.push({ description: heartbeats.blocked, key: 'heartbeats' });
1223
+ for (const description of uptime.blocked)
1224
+ unmanaged.push({ description, key: 'uptime' });
1102
1225
  return { plannedSteps: plannedSteps.map(withStepSystem), unmanaged, versionChanges };
1103
1226
  }
1104
1227
  /**
@@ -2027,6 +2150,63 @@ function planEdge(platform, recordedIds, executorBlocked) {
2027
2150
  blocked: null,
2028
2151
  };
2029
2152
  }
2153
+ /**
2154
+ * #604: the platform's ZITADEL as a sign-in provider in Cloudflare Access: a
2155
+ * ZITADEL client for Cloudflare, then edge's `ensureProvider`. One
2156
+ * `cloudflare:access-provider` step. It needs edge and the platform's sign-in
2157
+ * applied (it reads the Access team address through the one and makes the
2158
+ * client in the other). Done once `ACCESS_PROVIDER_RECORD_KEY` is recorded.
2159
+ *
2160
+ * Blocked, not planned: edge or sign-in not applied, a client minted but never
2161
+ * delivered (its secret cannot be shown again), or `executorBlocked` (no
2162
+ * dependencies, or edge cannot confirm Access is ready).
2163
+ */
2164
+ function planAccessProvider(platform, recordedIds, executorBlocked) {
2165
+ if (!platform.edge.enabled || !platform.auth.enabled)
2166
+ return { step: null, blocked: null };
2167
+ if (recordedIds[ACCESS_PROVIDER_RECORD_KEY]) {
2168
+ // The secret is not stored, so a changed address cannot be re-sent: a person remakes the client.
2169
+ const appliedHostname = recordedIds[PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY];
2170
+ if (appliedHostname && appliedHostname !== platform.auth.hostname) {
2171
+ return {
2172
+ step: null,
2173
+ blocked: `the Access sign-in provider is blocked, not planned: it was applied with the sign-in hostname ${appliedHostname} and ${platform.auth.hostname} is declared now; its secret is not stored, so delete the ZITADEL client cloudflare-access, remove the recorded keys ${ACCESS_PROVIDER_RECORD_KEY}, ${PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY} and ${PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY}, then apply again`,
2174
+ };
2175
+ }
2176
+ return { step: null, blocked: null };
2177
+ }
2178
+ const undelivered = recordedIds[PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY];
2179
+ if (undelivered) {
2180
+ return {
2181
+ step: null,
2182
+ blocked: `the Access sign-in provider is blocked, not planned: its ZITADEL client (${undelivered}) was made and its secret may or may not have reached edge; it cannot be shown again, so delete that client in ZITADEL and remove the recorded keys ${PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY} and ${ACCESS_PROVIDER_RECORD_KEY}, then apply again to make both anew`,
2183
+ };
2184
+ }
2185
+ const applied = Boolean(recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]) &&
2186
+ Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_AUTH_KEY)]) &&
2187
+ // The sibling ZITADEL steps need the delivered admin key (or the instance it came from).
2188
+ (Boolean(recordedIds[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) ||
2189
+ Boolean(recordedIds[PLATFORM_AUTH_INSTANCE_SCOPE_KEY]));
2190
+ if (!applied) {
2191
+ return {
2192
+ step: null,
2193
+ blocked: 'the Access sign-in provider is blocked, not planned: edge and the platform sign-in are not both applied yet',
2194
+ };
2195
+ }
2196
+ if (executorBlocked)
2197
+ return { step: null, blocked: executorBlocked };
2198
+ return {
2199
+ step: {
2200
+ action: { system: 'cloudflare', kind: 'access-provider', key: 'platform', desired: {} },
2201
+ step: {
2202
+ description: "make the platform's ZITADEL the sign-in provider in Cloudflare Access, for company gates",
2203
+ destroys: false,
2204
+ migrates: false,
2205
+ },
2206
+ },
2207
+ blocked: null,
2208
+ };
2209
+ }
2030
2210
  /**
2031
2211
  * #540: a self-hosted heartbeat server (Healthchecks) for the platform, for
2032
2212
  * now -- a Coolify Postgres plus one Docker-image app
@@ -2157,6 +2337,106 @@ function planHeartbeats(platform, recordedIds, hcReady, executorMissing, apiKeyM
2157
2337
  blocked: null,
2158
2338
  };
2159
2339
  }
2340
+ /**
2341
+ * foundry#542: the uptime Worker, one `cloudflare:worker` step. Blocked, not
2342
+ * planned (rule 2), while no artifact is pinned, Archon is not applied, the
2343
+ * executor lacks its dependencies, a Worker or Access token of the name exists
2344
+ * that foundry did not make. A lost Worker token or Access pair is not a
2345
+ * block: the step is planned and repairs it (rule 4). Planned again when the
2346
+ * pinned tag or sha256, the Archon hostname (`ARCHON_BASE_URL`), the plain
2347
+ * text variables or the set of secrets that can be set changed (rule 3), or
2348
+ * when a half-made deploy never recorded its applied values; it mints nothing
2349
+ * that is already held. When the platform no longer enables the Worker, a
2350
+ * destructive step removes whatever of it is recorded (edge removes its KV
2351
+ * namespace with the script).
2352
+ *
2353
+ * The returned `blocked` lines are reported in `unmanaged`; some accompany a
2354
+ * planned step (a secret whose source does not exist yet), because the step
2355
+ * still delivers everything that exists.
2356
+ */
2357
+ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archonApplied, uptimeHeld = {
2358
+ token: true,
2359
+ access: true,
2360
+ vars: '',
2361
+ }) {
2362
+ const { uptime } = platform;
2363
+ const deployed = Boolean(recordedIds[resourceKey('cloudflare', 'worker', PLATFORM_UPTIME_KEY)]) &&
2364
+ !recordedIds[PLATFORM_UPTIME_REMOVED_SCOPE_KEY];
2365
+ const action = (desired) => ({
2366
+ system: 'cloudflare',
2367
+ kind: 'worker',
2368
+ key: PLATFORM_UPTIME_KEY,
2369
+ desired,
2370
+ });
2371
+ if (!uptime.enabled) {
2372
+ if (!uptimeRecorded(recordedIds))
2373
+ return { step: null, blocked: [] };
2374
+ if (executorBlocked)
2375
+ return { step: null, blocked: [executorBlocked] };
2376
+ return {
2377
+ step: {
2378
+ action: action({ remove: true }),
2379
+ step: {
2380
+ description: `remove the uptime Worker ${PLATFORM_UPTIME_SCRIPT_NAME} and, with it, its KV namespace, its Access service token and its held token`,
2381
+ destroys: true,
2382
+ migrates: false,
2383
+ },
2384
+ },
2385
+ blocked: [],
2386
+ };
2387
+ }
2388
+ if (!uptime.tag || !uptime.sha256) {
2389
+ return {
2390
+ step: null,
2391
+ blocked: [
2392
+ 'the uptime Worker is blocked, not planned: no artifact is pinned; declare uptime.tag (an Archon release tag) and uptime.sha256 (the digest of archon-uptime-worker.js at that tag)',
2393
+ ],
2394
+ };
2395
+ }
2396
+ if (!archonApplied) {
2397
+ return {
2398
+ step: null,
2399
+ blocked: [
2400
+ 'the uptime Worker is blocked, not planned: platform:archon has not been applied yet',
2401
+ ],
2402
+ };
2403
+ }
2404
+ if (executorBlocked)
2405
+ return { step: null, blocked: [executorBlocked] };
2406
+ const missing = [];
2407
+ if (!heldOptional.includes('CHAT_ACCESS_TOKEN')) {
2408
+ missing.push(`the uptime Worker has no chat credential (CHAT_ACCESS_TOKEN): hold one under ${PLATFORM_UPTIME_CHAT_TOKEN_REF} and it is set on the next apply; until then it sends no chat alerts`);
2409
+ }
2410
+ if (!heldOptional.includes('MAIL_API_KEY')) {
2411
+ missing.push(`the uptime Worker has no mail API key (MAIL_API_KEY): hold one under ${PLATFORM_UPTIME_MAIL_KEY_REF} and it is set on the next apply; until then it sends no alert email`);
2412
+ }
2413
+ const hostname = platform.archon.hostname;
2414
+ // Applied only once a run got to its end and recorded these: a Worker that
2415
+ // was uploaded and then failed has its key but none of the applied values.
2416
+ const applied = deployed &&
2417
+ Boolean(recordedIds[PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY]) &&
2418
+ uptimeHeld.token &&
2419
+ Boolean(recordedIds[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY]) &&
2420
+ uptimeHeld.access &&
2421
+ recordedIds[PLATFORM_UPTIME_APPLIED_TAG_SCOPE_KEY] === uptime.tag &&
2422
+ recordedIds[PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY] === uptime.sha256 &&
2423
+ recordedIds[PLATFORM_UPTIME_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
2424
+ (recordedIds[PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY] ?? '') === uptimeHeld.vars &&
2425
+ recordedIds[PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY] === uptimeSecretNames(heldOptional);
2426
+ if (applied)
2427
+ return { step: null, blocked: missing };
2428
+ return {
2429
+ step: {
2430
+ action: action({ hostname, gitRef: uptime.tag, sha256: uptime.sha256 }),
2431
+ step: {
2432
+ description: `deploy the uptime Worker ${PLATFORM_UPTIME_SCRIPT_NAME} from Archon release ${uptime.tag}, probing https://${hostname}, with its KV namespace, secrets and cron trigger`,
2433
+ destroys: false,
2434
+ migrates: false,
2435
+ },
2436
+ },
2437
+ blocked: missing,
2438
+ };
2439
+ }
2160
2440
  /**
2161
2441
  * #172: the platform's AI host (https://github.com/wtfalch/ai) -- a Coolify
2162
2442
  * Postgres plus three apps built from that repo's root Dockerfile, one per
@@ -2304,6 +2584,14 @@ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted,
2304
2584
  // other drift reason.
2305
2585
  recordedIds[PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY] === '1' &&
2306
2586
  !recordedIds[PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY] &&
2587
+ // foundry#542: the uptime token minted after Archon's last apply is not
2588
+ // in Archon's env yet.
2589
+ // A disabled Worker leaves the token in Archon's env until the step drops it.
2590
+ (platform.uptime.enabled
2591
+ ? !recordedIds[PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY] ||
2592
+ recordedIds[PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY] ===
2593
+ recordedIds[PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY]
2594
+ : !recordedIds[PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY]) &&
2307
2595
  // #398: a rotated integrations key id Archon's env was not yet written
2308
2596
  // with (see PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY). Absent
2309
2597
  // before the first rotation on a platform applied before #398.
@@ -87,6 +87,8 @@ export interface OrgPlatformReadiness {
87
87
  callsReady: boolean;
88
88
  /** The platform's own sign-in (#168), which every org's own ZITADEL org lives inside. */
89
89
  authReady: boolean;
90
+ /** The Access provider id the platform recorded (`ACCESS_PROVIDER_RECORD_KEY`), which every gate requires. `null` or absent until the platform has recorded it. */
91
+ accessProviderId?: string | null;
90
92
  /** The jobs scheduler (#173) is enabled, applied, has recorded its internal address, and its admin token is held. */
91
93
  jobsReady: boolean;
92
94
  /**
@@ -1,3 +1,4 @@
1
+ import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
1
2
  import { JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AUTH_KEY, PLATFORM_EDGE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
2
3
  import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './platform-providers.js';
3
4
  /**
@@ -129,6 +130,9 @@ edgePortPresent = false) {
129
130
  aiReady: aiApplied && missingKeys.ai === null,
130
131
  aiUrl: aiApplied ? (platformRecordedIds[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY] ?? null) : null,
131
132
  callsReady: platform.sfu.enabled,
133
+ ...(platformRecordedIds[ACCESS_PROVIDER_RECORD_KEY]
134
+ ? { accessProviderId: platformRecordedIds[ACCESS_PROVIDER_RECORD_KEY] }
135
+ : {}),
132
136
  authReady: platform.auth.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_AUTH_KEY),
133
137
  jobsReady: jobsApplied && jobsUrl !== undefined && missingKeys.jobs === null,
134
138
  ...(jobsApplied && jobsUrl === undefined ? { jobsUrlMissing: true } : {}),
@@ -12,6 +12,7 @@ import type { LiveResource, PlatformCloudflareReader, PlatformCoolifyReader } fr
12
12
  import type { PlatformStepExecutionContext, PlatformStepExecutor } from './plan-apply.js';
13
13
  import { type BrokerDeployRecord } from './platform-diff.js';
14
14
  import { type CallsSfuPlatformDeps } from './platform-sfu.js';
15
+ import { type PlatformUptimeDeps } from './uptime-worker.js';
15
16
  /**
16
17
  * #168: everything {@link provisionPlatformAuth} needs beyond
17
18
  * {@link PlatformStepExecutionContext} -- a bundle, not more constructor
@@ -197,6 +198,18 @@ export interface FoundryPlatformStepExecutorOptions {
197
198
  * heartbeat checks reported as blocked rather than planned.
198
199
  */
199
200
  heartbeats?: PlatformHeartbeatsDeps;
201
+ /**
202
+ * foundry#542: dependencies for the uptime Worker step
203
+ * (`cloudflare:worker` keyed {@link PLATFORM_UPTIME_KEY}) -- optional for
204
+ * the same reason as `auth`; without it the step is reported blocked.
205
+ */
206
+ uptime?: PlatformUptimeDeps;
207
+ /**
208
+ * #604: dependencies for the `cloudflare:access-provider` step -- optional
209
+ * for the same reason as `auth`; edge and sign-in applied with this unset
210
+ * gets the step reported as blocked.
211
+ */
212
+ accessProvider?: PlatformAccessProviderDeps;
200
213
  }
201
214
  /**
202
215
  * The real {@link PlatformStepExecutor} for the two steps built so far:
@@ -279,6 +292,8 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
279
292
  private readonly backup?;
280
293
  private readonly hc?;
281
294
  private readonly heartbeats?;
295
+ private readonly uptime?;
296
+ private readonly accessProvider?;
282
297
  constructor(cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'createZone'>, declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone' | 'list' | 'get'>, options?: FoundryPlatformStepExecutorOptions);
283
298
  blockedSteps(platform: DeclaredPlatform, recordedIds?: Readonly<Record<string, string>>): Promise<{
284
299
  auth?: string;
@@ -290,13 +305,29 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
290
305
  edge?: string;
291
306
  archon?: string;
292
307
  mail?: string;
308
+ accessProvider?: string;
293
309
  backupExecutorMissing?: boolean;
294
310
  hc?: string;
295
311
  heartbeatsExecutorMissing?: boolean;
296
312
  heartbeatsApiKeyMissing?: boolean;
313
+ uptime?: string;
314
+ uptimeSecretsHeld?: string[];
315
+ uptimeHeld?: {
316
+ token: boolean;
317
+ access: boolean;
318
+ vars: string;
319
+ };
297
320
  archonRunsInsideApp?: boolean;
298
321
  archonDeployRunning?: string;
299
322
  }>;
323
+ /**
324
+ * foundry#542: why the uptime Worker cannot run yet, which optional secrets
325
+ * are held, and whether the minted values are held. A Worker token or Access
326
+ * pair that is gone is not a block here: the step repairs it. The planner
327
+ * checks the pin and Archon first, so none of the reads here happens before
328
+ * they hold.
329
+ */
330
+ private uptimeBlocked;
300
331
  /**
301
332
  * Whether Archon's live Access policy lets through anyone but the declared
302
333
  * emails, or (#211 round 4) the broker it last registered with no longer
@@ -959,6 +990,22 @@ export interface PlatformEdgeDeps {
959
990
  /** Clock, sleep and fetch for the readiness wait and delivery retries; real ones by default. */
960
991
  runtime?: PlatformEdgeRuntime;
961
992
  }
993
+ /** The Access calls the provider step makes. */
994
+ export type PlatformAccessProviderPort = Pick<AccessPort, 'readiness' | 'ensureProvider'>;
995
+ /** #604: dependencies for {@link provisionPlatformAccessProvider}. */
996
+ export interface PlatformAccessProviderDeps {
997
+ /** Edge's Access calls, at its recorded address (see {@link PlatformAccessFactory}). */
998
+ access: (edgeUrl: string) => PlatformAccessProviderPort | Promise<PlatformAccessProviderPort>;
999
+ /** Must target the platform's own ZITADEL instance, or `zitadelForInstance` must be set. */
1000
+ zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'findOrCreateProject' | 'listApps' | 'deleteApp'> & Required<Pick<ZitadelPort, 'createOidcAppWithSecret'>>;
1001
+ /** {@link PlatformArchonDeps.zitadelForInstance}: the ZITADEL client for the sign-in at `issuerUrl`. */
1002
+ zitadelForInstance?: (issuerUrl: string) => PlatformAccessProviderDeps['zitadel'];
1003
+ /** Sleep and delivery tries for the one call that carries the secret; real ones by default. */
1004
+ runtime?: {
1005
+ sleep?: (ms: number) => Promise<void>;
1006
+ deliverAttempts?: number;
1007
+ };
1008
+ }
962
1009
  export interface PlatformHcRuntime {
963
1010
  fetch?: typeof fetch;
964
1011
  sleep?: (ms: number) => Promise<void>;