@wtfalch/foundry 0.24.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.
@@ -146,5 +146,9 @@ export function declaredPlatformFor(apex, serverIpv4) {
146
146
  apex,
147
147
  ...(serverIpv4 ? { serverIpv4 } : {}),
148
148
  ...services,
149
+ // Off, like `sfu`: the pin is an Archon release tag and digest, which exist
150
+ // only after Archon runs. Declared on with no pin, it would be reported
151
+ // blocked and the bootstrap would never be complete.
152
+ uptime: { enabled: false, tag: '', sha256: '' },
149
153
  };
150
154
  }
package/dist/index.d.ts CHANGED
@@ -41,7 +41,7 @@ export { checkMailIdentityAudience } from './plan/index.js';
41
41
  export type { MailAllowlistApp } from './plan/index.js';
42
42
  export { checkMailAppsAllowlist } from './plan/index.js';
43
43
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
44
- export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './plan/index.js';
44
+ export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeDeps, PlatformWorkersFactory, PlatformWorkersPort, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './plan/index.js';
45
45
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './plan/index.js';
46
46
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './plan/index.js';
47
47
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
@@ -0,0 +1,11 @@
1
+ -- @wtfalch/foundry: the uptime Worker on the declared platform (foundry#542).
2
+ -- Mirrors `src/store/schema.ts`'s `foundryDeclaredPlatform` additions.
3
+ -- `uptime_tag` and `uptime_sha256` are the pin of the Archon release whose
4
+ -- built Worker foundry deploys; both empty means nothing is pinned yet, and
5
+ -- the planner reports that instead of planning the step. The Worker is on by
6
+ -- default, like every service except sfu and coolify.
7
+
8
+ alter table foundry_declared_platform
9
+ add column if not exists uptime_enabled boolean not null default true,
10
+ add column if not exists uptime_tag text not null default '',
11
+ add column if not exists uptime_sha256 text not null default '';
@@ -29,6 +29,7 @@ export interface AccessPort {
29
29
  listAccessPolicies(appId: string): Promise<Array<{
30
30
  id: string;
31
31
  name: string;
32
+ decision?: AccessDecision;
32
33
  include?: AccessPolicyRule[];
33
34
  }>>;
34
35
  createAccessPolicy(appId: string, policy: {
@@ -46,6 +47,19 @@ export interface AccessPort {
46
47
  findServiceTokenByName(name: string): Promise<{
47
48
  id: string;
48
49
  } | undefined>;
50
+ /**
51
+ * Mints a service token through edge (edge 0.12.0). `clientId` and
52
+ * `clientSecret` come back once and are never returned again; a token of that
53
+ * name, made by edge or not, answers `EdgeApiError` `conflict`.
54
+ */
55
+ createServiceToken(name: string): Promise<{
56
+ id: string;
57
+ name: string;
58
+ clientId: string;
59
+ clientSecret: string;
60
+ }>;
61
+ /** Removes a token edge created. `not_found` for any other; a token already gone is success. */
62
+ removeServiceToken(id: string): Promise<void>;
49
63
  deleteAccessApp(appId: string): Promise<void>;
50
64
  /**
51
65
  * A company's gate: an Access application over `hostnames`, one policy
@@ -26,6 +26,8 @@ export function createEdgeAccess(client, options = {}) {
26
26
  listPolicies: (appId) => retrying(() => client.listPolicies(appId)),
27
27
  createPolicy: (appId, input) => retrying(() => client.createPolicy(appId, input)),
28
28
  findServiceToken: (name) => retrying(() => client.findServiceToken(name)),
29
+ createServiceToken: (input) => retrying(() => client.createServiceToken(input)),
30
+ removeServiceToken: (id) => retrying(() => client.removeServiceToken(id)),
29
31
  adopt: (input) => retrying(() => client.adopt(input)),
30
32
  readiness: () => retrying(() => client.readiness()),
31
33
  ensureProvider: (input) => retrying(() => client.ensureProvider(input)),
@@ -103,7 +105,12 @@ export function createEdgeAccess(client, options = {}) {
103
105
  },
104
106
  async listAccessPolicies(appId) {
105
107
  const { policies } = await onApp(appId, () => access.listPolicies(appId));
106
- return policies.map((p) => ({ id: p.id, name: p.name, include: p.include }));
108
+ return policies.map((p) => ({
109
+ id: p.id,
110
+ name: p.name,
111
+ decision: p.decision,
112
+ include: p.include,
113
+ }));
107
114
  },
108
115
  async createAccessPolicy(appId, policy) {
109
116
  const created = await onApp(appId, () => writeSlot(() => access.createPolicy(appId, policy)));
@@ -121,6 +128,19 @@ export function createEdgeAccess(client, options = {}) {
121
128
  throw error;
122
129
  }
123
130
  },
131
+ async createServiceToken(name) {
132
+ // Not retried on anything but `rate_limited`: a second mint would be a second token.
133
+ const minted = await writeSlot(() => access.createServiceToken({ name }));
134
+ return {
135
+ id: minted.token.id,
136
+ name: minted.token.name,
137
+ clientId: minted.clientId,
138
+ clientSecret: minted.clientSecret,
139
+ };
140
+ },
141
+ async removeServiceToken(id) {
142
+ await writeSlot(() => access.removeServiceToken(id));
143
+ },
124
144
  async deleteAccessApp(appId) {
125
145
  await onApp(appId, () => writeSlot(() => access.remove(appId)));
126
146
  },
@@ -5,6 +5,21 @@ import type { WorkersPort } from './executor.js';
5
5
  export type EdgePlatformWorkers = ReturnType<typeof createEdgeClient>['platform']['workers'];
6
6
  /** Edge answers a first upload of a name that another upload is making, or a delete in flight, `conflict` + `busy`. */
7
7
  export declare const EDGE_WORKERS_BUSY_ATTEMPTS = 5;
8
+ /**
9
+ * What the platform's uptime Worker step needs beyond {@link WorkersPort}
10
+ * (edge 0.11.0): cron triggers and write-only secrets, both for a script edge
11
+ * owns. A secret with a `version` edge already holds is skipped; a new
12
+ * `version` sets the value again. Edge never returns a value, and nothing here
13
+ * keeps one.
14
+ */
15
+ export interface PlatformWorkersPort extends WorkersPort {
16
+ setSchedules(name: string, crons: readonly string[]): Promise<void>;
17
+ setSecrets(name: string, secrets: readonly {
18
+ name: string;
19
+ value: string;
20
+ version: string;
21
+ }[]): Promise<void>;
22
+ }
8
23
  /** What the host supplies: fetches the artifact and returns its bytes, verified against `sha256`. Foundry never fetches. */
9
24
  export interface EdgeWorkersOptions {
10
25
  fetchArtifact: (url: string, sha256: string) => Promise<Uint8Array>;
@@ -28,4 +43,4 @@ export interface EdgeWorkersOptions {
28
43
  * foundry refuses to overwrite is never adopted. A 404 on a delete means gone.
29
44
  * A `rate_limited` or `busy` answer is retried.
30
45
  */
31
- export declare function createEdgeWorkers(client: EdgePlatformWorkers, options: EdgeWorkersOptions): WorkersPort;
46
+ export declare function createEdgeWorkers(client: EdgePlatformWorkers, options: EdgeWorkersOptions): PlatformWorkersPort;
@@ -149,6 +149,14 @@ export function createEdgeWorkers(client, options) {
149
149
  }
150
150
  adoptedRoutes.delete(`${zoneId}/${routeId}`);
151
151
  },
152
+ async setSchedules(name, crons) {
153
+ await adoptScript(name);
154
+ await writeSlot(() => retrying(() => client.setSchedules(name, { crons: [...crons] })));
155
+ },
156
+ async setSecrets(name, secrets) {
157
+ await adoptScript(name);
158
+ await writeSlot(() => retrying(() => client.setSecrets(name, { secrets: secrets.map((s) => ({ ...s })) })));
159
+ },
152
160
  async deleteScript(name) {
153
161
  if (!(await adoptScript(name)))
154
162
  return;
@@ -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?: WorkersRateLimitBinding[];
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<{
@@ -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';
@@ -224,6 +224,10 @@ export interface PlatformStepExecutor {
224
224
  hc?: string;
225
225
  /** #540: see `PlanPlatformOptions.blocked.heartbeatsExecutorMissing` (`platform-diff.ts`). */
226
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[];
227
231
  /** See `PlanPlatformOptions.blocked.archonRunsInsideApp`. */
228
232
  archonRunsInsideApp?: boolean;
229
233
  /** See `PlanPlatformOptions.blocked.archonDeployRunning`. */
@@ -822,6 +822,79 @@ export declare function heartbeatCheckUrlRef(name: string): string;
822
822
  * bootstrapping at all.
823
823
  */
824
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;
825
898
  /**
826
899
  * Options for {@link planPlatform}, replacing four positional arguments that
827
900
  * shifted on every merge that added a platform service (#186, #187, #188) --
@@ -898,6 +971,21 @@ export interface PlanPlatformOptions {
898
971
  * Not a block.
899
972
  */
900
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
+ };
901
989
  /** Why Archon's step must wait: the deployment it triggered is still running. Blocks even an applied step. */
902
990
  archonDeployRunning?: string;
903
991
  };
@@ -1,3 +1,4 @@
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';
@@ -920,6 +921,100 @@ export function heartbeatCheckUrlRef(name) {
920
921
  * bootstrapping at all.
921
922
  */
922
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
+ }
923
1018
  /**
924
1019
  * #167: the platform's own diff engine, mirroring `diff.ts`'s
925
1020
  * `computePlanDetail` for the one shared {@link DeclaredPlatform} record
@@ -1035,6 +1130,11 @@ export function planPlatform(platform, live, options = {}) {
1035
1130
  const heartbeats = planHeartbeats(platform, recordedIds, hcReady, blocked.heartbeatsExecutorMissing ?? false, blocked.heartbeatsApiKeyMissing ?? false);
1036
1131
  if (heartbeats.step)
1037
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);
1038
1138
  if (archon.step && blocked.archonRunsInsideApp)
1039
1139
  plannedSteps.push(archon.step);
1040
1140
  // #251: one line per service whose recorded applied ref no longer matches
@@ -1120,6 +1220,8 @@ export function planPlatform(platform, live, options = {}) {
1120
1220
  unmanaged.push(...backups.blocked);
1121
1221
  if (heartbeats.blocked)
1122
1222
  unmanaged.push({ description: heartbeats.blocked, key: 'heartbeats' });
1223
+ for (const description of uptime.blocked)
1224
+ unmanaged.push({ description, key: 'uptime' });
1123
1225
  return { plannedSteps: plannedSteps.map(withStepSystem), unmanaged, versionChanges };
1124
1226
  }
1125
1227
  /**
@@ -2235,6 +2337,106 @@ function planHeartbeats(platform, recordedIds, hcReady, executorMissing, apiKeyM
2235
2337
  blocked: null,
2236
2338
  };
2237
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
+ }
2238
2440
  /**
2239
2441
  * #172: the platform's AI host (https://github.com/wtfalch/ai) -- a Coolify
2240
2442
  * Postgres plus three apps built from that repo's root Dockerfile, one per
@@ -2382,6 +2584,14 @@ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted,
2382
2584
  // other drift reason.
2383
2585
  recordedIds[PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY] === '1' &&
2384
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]) &&
2385
2595
  // #398: a rotated integrations key id Archon's env was not yet written
2386
2596
  // with (see PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY). Absent
2387
2597
  // before the first rotation on a platform applied before #398.
@@ -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,12 @@ 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;
200
207
  /**
201
208
  * #604: dependencies for the `cloudflare:access-provider` step -- optional
202
209
  * for the same reason as `auth`; edge and sign-in applied with this unset
@@ -285,6 +292,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
285
292
  private readonly backup?;
286
293
  private readonly hc?;
287
294
  private readonly heartbeats?;
295
+ private readonly uptime?;
288
296
  private readonly accessProvider?;
289
297
  constructor(cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'createZone'>, declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone' | 'list' | 'get'>, options?: FoundryPlatformStepExecutorOptions);
290
298
  blockedSteps(platform: DeclaredPlatform, recordedIds?: Readonly<Record<string, string>>): Promise<{
@@ -302,9 +310,24 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
302
310
  hc?: string;
303
311
  heartbeatsExecutorMissing?: boolean;
304
312
  heartbeatsApiKeyMissing?: boolean;
313
+ uptime?: string;
314
+ uptimeSecretsHeld?: string[];
315
+ uptimeHeld?: {
316
+ token: boolean;
317
+ access: boolean;
318
+ vars: string;
319
+ };
305
320
  archonRunsInsideApp?: boolean;
306
321
  archonDeployRunning?: string;
307
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;
308
331
  /**
309
332
  * Whether Archon's live Access policy lets through anyone but the declared
310
333
  * emails, or (#211 round 4) the broker it last registered with no longer