@wtfalch/foundry 0.65.0 → 0.67.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.
Files changed (88) hide show
  1. package/dist/apps/cloudflare.d.ts +5 -0
  2. package/dist/apps/deploy.js +33 -1
  3. package/dist/apps/migrations.d.ts +3 -0
  4. package/dist/apps/migrations.js +32 -7
  5. package/dist/catalogue/copy-en.js +5 -0
  6. package/dist/catalogue/integrations.js +5 -0
  7. package/dist/entry-rules.d.ts +11 -0
  8. package/dist/entry-rules.js +70 -0
  9. package/dist/index.d.ts +9 -4
  10. package/dist/index.js +5 -2
  11. package/dist/migrations/0069_release_events.sql +16 -0
  12. package/dist/modules/analytics.d.ts +8 -0
  13. package/dist/modules/analytics.js +28 -0
  14. package/dist/modules/database.js +22 -3
  15. package/dist/modules/index.js +2 -0
  16. package/dist/native-build.d.ts +16 -12
  17. package/dist/native-build.js +16 -12
  18. package/dist/org.d.ts +9 -1
  19. package/dist/org.js +4 -1
  20. package/dist/plan/analytics-boule.d.ts +17 -0
  21. package/dist/plan/analytics-boule.js +43 -0
  22. package/dist/plan/analytics.d.ts +58 -0
  23. package/dist/plan/analytics.js +89 -0
  24. package/dist/plan/boule.js +2 -0
  25. package/dist/plan/company-backups.js +2 -0
  26. package/dist/plan/diff.d.ts +4 -0
  27. package/dist/plan/diff.js +103 -3
  28. package/dist/plan/entry-analytics-key.d.ts +35 -0
  29. package/dist/plan/entry-analytics-key.js +134 -0
  30. package/dist/plan/executor.d.ts +79 -6
  31. package/dist/plan/executor.js +379 -19
  32. package/dist/plan/hostnames.js +4 -0
  33. package/dist/plan/index.d.ts +2 -0
  34. package/dist/plan/index.js +1 -0
  35. package/dist/plan/live.d.ts +1 -1
  36. package/dist/plan/locked-hostnames.js +3 -0
  37. package/dist/plan/login-host.d.ts +8 -0
  38. package/dist/plan/login-host.js +8 -0
  39. package/dist/plan/org-names.d.ts +3 -3
  40. package/dist/plan/org-names.js +3 -0
  41. package/dist/plan/own-limits.d.ts +6 -0
  42. package/dist/plan/own-limits.js +1 -0
  43. package/dist/plan/plan-apply.d.ts +12 -0
  44. package/dist/plan/plan-apply.js +40 -1
  45. package/dist/plan/platform-diff.d.ts +17 -1
  46. package/dist/plan/platform-diff.js +40 -2
  47. package/dist/plan/platform-org.d.ts +6 -0
  48. package/dist/plan/platform-providers.d.ts +8 -6
  49. package/dist/plan/platform-providers.js +56 -10
  50. package/dist/plan/providers-readers.js +16 -1
  51. package/dist/plan/release-host.d.ts +30 -1
  52. package/dist/plan/release-host.js +84 -41
  53. package/dist/plan/release-series-reader.d.ts +26 -0
  54. package/dist/plan/release-series-reader.js +40 -0
  55. package/dist/plan/sign-in-archon.d.ts +1 -1
  56. package/dist/plan/sign-in-archon.js +2 -2
  57. package/dist/plan/stage-services.d.ts +3 -1
  58. package/dist/plan/stage-services.js +2 -1
  59. package/dist/plan/uptime-worker.js +53 -3
  60. package/dist/plan/worker-app-database.d.ts +12 -1
  61. package/dist/plan/worker-app-database.js +13 -0
  62. package/dist/plan/worker-app.d.ts +27 -2
  63. package/dist/plan/worker-app.js +52 -7
  64. package/dist/plan/workspace.d.ts +2 -2
  65. package/dist/plan/workspace.js +3 -3
  66. package/dist/release/decide.d.ts +2 -0
  67. package/dist/release/decide.js +67 -10
  68. package/dist/release/events.d.ts +13 -0
  69. package/dist/release/events.js +67 -0
  70. package/dist/release/health.d.ts +5 -0
  71. package/dist/release/health.js +3 -3
  72. package/dist/release/run.d.ts +7 -2
  73. package/dist/release/run.js +8 -0
  74. package/dist/release/series.d.ts +58 -0
  75. package/dist/release/series.js +65 -0
  76. package/dist/release/state.d.ts +58 -1
  77. package/dist/release/state.js +45 -0
  78. package/dist/release/types.d.ts +44 -0
  79. package/dist/slug.d.ts +1 -1
  80. package/dist/slug.js +5 -2
  81. package/dist/store/postgres.js +29 -3
  82. package/dist/store/schema.d.ts +112 -0
  83. package/dist/store/schema.js +9 -0
  84. package/dist/worker-app-config.d.ts +22 -1
  85. package/dist/worker-app-config.js +73 -15
  86. package/dist/worker-app-migrations.d.ts +6 -2
  87. package/dist/worker-app-migrations.js +1 -0
  88. package/package.json +1 -1
@@ -1,5 +1,7 @@
1
1
  import { type ReleaseDeps, type StageCopyJob, type StageCopyOutcome } from '../release/run.js';
2
+ import { type ReleaseSeriesQuery } from '../release/series.js';
2
3
  import { type ReleaseStore } from '../release/state.js';
4
+ import type { ReleaseSeries } from '../release/types.js';
3
5
  import { type BlueGreenSubject, type PinHeldKeys } from './blue-green.js';
4
6
  import { type DeployHealthRuntime } from './deploy-health.js';
5
7
  import type { CoolifyPort } from './executor.js';
@@ -18,6 +20,11 @@ export interface ReleaseHost {
18
20
  refreshStage(): Promise<void>;
19
21
  /** Writes the recorded weights to the balancer on every server that has one. */
20
22
  writeWeights(): Promise<void>;
23
+ /**
24
+ * Reporting's release series for `entry` (C2), read through the balancer like the health read. Null when
25
+ * `entry` is not this host's, the entry has no reporting, or the read failed.
26
+ */
27
+ readSeries?(entry: string, query: ReleaseSeriesQuery): Promise<ReleaseSeries | null>;
21
28
  }
22
29
  export interface ReleaseHostInput {
23
30
  subject: BlueGreenSubject;
@@ -47,6 +54,27 @@ export interface ReleaseHostInput {
47
54
  /** A stage deploy waits at most this many polls of this long (10 minutes) for Coolify's deployment to finish. */
48
55
  export declare const STAGE_DEPLOY_ATTEMPTS = 60;
49
56
  export declare const STAGE_DEPLOY_INTERVAL_MS = 10000;
57
+ /** What the balancer read of a subject needs: its address, its pin key and its health token. */
58
+ interface SubjectRead {
59
+ subject: BlueGreenSubject;
60
+ healthToken: ReleaseHostInput['healthToken'];
61
+ secret: ReleaseHostInput['secret'];
62
+ pinRef: string;
63
+ heldKeys?: PinHeldKeys;
64
+ fetch?: typeof fetch;
65
+ renew?: () => Promise<void>;
66
+ label: string;
67
+ }
68
+ type Recorded = () => Readonly<Record<string, string>>;
69
+ /**
70
+ * Reporting's release series of one subject (C2), read through the balancer like the health read. Null when the
71
+ * subject has no reporting, the token is not held, or the read failed. Used by a release run and, outside a run,
72
+ * by {@link readEntryReleaseSeries}.
73
+ */
74
+ export declare function readSubjectSeries(read: SubjectRead & {
75
+ recorded: Recorded;
76
+ query: ReleaseSeriesQuery;
77
+ }): Promise<ReleaseSeries | null>;
50
78
  export declare function createReleaseHost(input: ReleaseHostInput): ReleaseHost;
51
79
  /**
52
80
  * One release run for one subject, writing into `context` (the live recorded ids of the run that holds
@@ -56,7 +84,7 @@ export declare function createReleaseHost(input: ReleaseHostInput): ReleaseHost;
56
84
  * (like the next apply, through its `bluegreen-lb` step) writes it again.
57
85
  */
58
86
  export declare function runSubjectRelease(input: {
59
- store: Pick<ReleaseStore, 'openRequests' | 'consumeRequests'>;
87
+ store: Pick<ReleaseStore, 'openRequests' | 'consumeRequests'> & Partial<Pick<ReleaseStore, 'appendEvents'>>;
60
88
  scopeOwnerId: string;
61
89
  subject: BlueGreenSubject;
62
90
  host: ReleaseHost;
@@ -90,3 +118,4 @@ export declare function finishStageCopy(input: {
90
118
  outcome: StageCopyOutcome;
91
119
  refused: string[];
92
120
  }>;
121
+ export {};
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { checkColorAddress, readReleaseHealth } from '../release/health.js';
3
3
  import { runRelease, runStageCopy, settleStageCopy, } from '../release/run.js';
4
+ import { fetchReleaseSeries } from '../release/series.js';
4
5
  import { releaseKeys } from '../release/state.js';
5
6
  import { applyBlueGreenWeights, copiedEnv } from './blue-green-apply.js';
6
7
  import { ACTIONS_KEY_ENV, DEFAULT_WEIGHTS, LB_COLOR_HEADER, LB_PIN_HEADER, bgKey, formatWeights, mintPinKey, parseWeights, readPinKey, reportingEnv, } from './blue-green.js';
@@ -20,6 +21,68 @@ function statusOf(status) {
20
21
  return 'other';
21
22
  }
22
23
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
24
+ const pinInputOf = (read, recorded) => ({
25
+ id: read.subject.id,
26
+ ref: read.pinRef,
27
+ recorded,
28
+ secret: read.secret,
29
+ heldKeys: read.heldKeys ?? { resolve: async () => null },
30
+ });
31
+ /**
32
+ * Where the runner reaches a color: the entry's first public hostname, pinned to the color by the balancer's
33
+ * header router. The balancer routes the pinned request to that color whatever its weight.
34
+ */
35
+ async function colorAddress(read, recorded, color) {
36
+ const hostname = read.subject.hostnames.find((name) => !name.startsWith('*'));
37
+ if (!hostname)
38
+ throw new Error(`${read.label}: the app has no public hostname to check through`);
39
+ const pinKey = await readPinKey(pinInputOf(read, recorded));
40
+ if (!pinKey)
41
+ throw new Error(`${read.label}: the balancer's pin key is not held`);
42
+ return {
43
+ address: `https://${hostname}`,
44
+ headers: { [LB_COLOR_HEADER]: color, [LB_PIN_HEADER]: pinKey },
45
+ };
46
+ }
47
+ /** The color a health read goes to: the one being released, else the live one. */
48
+ function healthColorOf(ids, id) {
49
+ try {
50
+ const window = JSON.parse(ids[bgKey.window(id)] ?? '{}');
51
+ if (window.release?.to === 'blue' || window.release?.to === 'green')
52
+ return window.release.to;
53
+ }
54
+ catch {
55
+ // A damaged window reads as no release; the runner rewrites it.
56
+ }
57
+ return ids[bgKey.live(id)] === 'green' ? 'green' : 'blue';
58
+ }
59
+ /**
60
+ * Reporting's release series of one subject (C2), read through the balancer like the health read. Null when the
61
+ * subject has no reporting, the token is not held, or the read failed. Used by a release run and, outside a run,
62
+ * by {@link readEntryReleaseSeries}.
63
+ */
64
+ export async function readSubjectSeries(read) {
65
+ if (!read.subject.reporting)
66
+ return null;
67
+ try {
68
+ await read.renew?.();
69
+ const token = await read.healthToken();
70
+ if (!token)
71
+ return null;
72
+ read.secret(token);
73
+ return await fetchReleaseSeries({
74
+ ...(await colorAddress(read, read.recorded, healthColorOf(read.recorded(), read.subject.id))),
75
+ token,
76
+ query: read.query,
77
+ ...(read.fetch ? { fetchImpl: read.fetch } : {}),
78
+ });
79
+ }
80
+ catch {
81
+ // No hostname or pin key yet: the page shows no chart. The cause can name a hostname, so it is not logged.
82
+ console.error(`${read.label}: reading the release series failed`);
83
+ return null;
84
+ }
85
+ }
23
86
  export function createReleaseHost(input) {
24
87
  const { subject, context, coolify } = input;
25
88
  const id = subject.id;
@@ -43,41 +106,9 @@ export function createReleaseHost(input) {
43
106
  };
44
107
  const colorApps = (color) => colorAppsOnServers(color).map((a) => a.appId);
45
108
  const primaryColorApp = (color) => color === 'blue' ? input.primaryBlueAppId : recorded()[bgKey.app(id, input.primaryUuid, color)];
46
- /**
47
- * Where the runner reaches a color: the entry's first public hostname, pinned to the color by the balancer's
48
- * header router. The balancer routes the pinned request to that color whatever its weight.
49
- */
50
- const pinInput = () => ({
51
- id,
52
- ref: input.pinRef,
53
- recorded,
54
- secret: input.secret,
55
- heldKeys: input.heldKeys ?? { resolve: async () => null },
56
- });
57
- const addressOf = async (color) => {
58
- const hostname = subject.hostnames.find((name) => !name.startsWith('*'));
59
- if (!hostname)
60
- throw new Error(`${input.label}: the app has no public hostname to check through`);
61
- const pinKey = await readPinKey(pinInput());
62
- if (!pinKey)
63
- throw new Error(`${input.label}: the balancer's pin key is not held`);
64
- return {
65
- address: `https://${hostname}`,
66
- headers: { [LB_COLOR_HEADER]: color, [LB_PIN_HEADER]: pinKey },
67
- };
68
- };
69
- /** The color a health read goes to: the one being released, else the live one. */
70
- const healthColor = () => {
71
- try {
72
- const window = JSON.parse(recorded()[bgKey.window(id)] ?? '{}');
73
- if (window.release?.to === 'blue' || window.release?.to === 'green')
74
- return window.release.to;
75
- }
76
- catch {
77
- // A damaged window reads as no release; the runner rewrites it.
78
- }
79
- return recorded()[bgKey.live(id)] === 'green' ? 'green' : 'blue';
80
- };
109
+ const pinInput = () => pinInputOf(input, recorded);
110
+ const addressOf = (color) => colorAddress(input, recorded, color);
111
+ const healthColor = () => healthColorOf(recorded(), id);
81
112
  const deployTo = async (color, digest) => {
82
113
  if (!COMMIT.test(digest))
83
114
  throw new Error(`${input.label}: the release is not a built commit`);
@@ -250,6 +281,11 @@ export function createReleaseHost(input) {
250
281
  };
251
282
  return {
252
283
  deps,
284
+ async readSeries(entry, query) {
285
+ if (entry !== id)
286
+ return null;
287
+ return readSubjectSeries({ ...input, recorded, query });
288
+ },
253
289
  async refreshStage() {
254
290
  const stageId = recorded()[bgKey.app(id, input.primaryUuid, 'stage')];
255
291
  if (!stageId || !coolify.listApplicationDeployments)
@@ -324,13 +360,7 @@ export async function runSubjectRelease(input) {
324
360
  const result = await runRelease({
325
361
  store: {
326
362
  getScopeIds: async () => ({ ...ids() }),
327
- addRequest: async () => {
328
- throw new Error('the release runner takes requests; it does not add them');
329
- },
330
363
  openRequests: (query) => input.store.openRequests(query),
331
- consumeRequests: async () => {
332
- throw new Error('the release runner consumes requests after it persists');
333
- },
334
364
  },
335
365
  orgId: input.scopeOwnerId,
336
366
  entryKey: id,
@@ -347,6 +377,19 @@ export async function runSubjectRelease(input) {
347
377
  context.recordScopeId(key, value);
348
378
  }
349
379
  await context.persist();
380
+ if (input.store.appendEvents && result.events.length > 0) {
381
+ try {
382
+ await input.store.appendEvents({
383
+ orgId: input.scopeOwnerId,
384
+ entryKey: id,
385
+ events: result.events,
386
+ });
387
+ }
388
+ catch {
389
+ // The history is a record, not a gate: losing a line must not leave the run's requests open to replay.
390
+ console.error(`release ${id}: recording ${result.events.length} history lines failed`);
391
+ }
392
+ }
350
393
  await input.store.consumeRequests(result.requestIds);
351
394
  const after = parseWeights(ids()[bgKey.weights(id)]);
352
395
  // Also when a balancer lags the weights (an earlier write failed), so the next tick retries it.
@@ -0,0 +1,26 @@
1
+ import type { ReleaseSeriesQuery } from '../release/series.js';
2
+ import type { ReleaseStore } from '../release/state.js';
3
+ import type { ReleaseSeries } from '../release/types.js';
4
+ import type { DeclaredOrgStore } from '../store.js';
5
+ import { type PinHeldKeys } from './blue-green.js';
6
+ /** What a request needs to read a series outside a release run: what Archon already holds. */
7
+ export interface EntrySeriesDeps {
8
+ orgs: Pick<DeclaredOrgStore, 'get'>;
9
+ /** The recorded ids of the org, as the release tick persists them (`createPostgresReleaseStore`). */
10
+ store: Pick<ReleaseStore, 'getScopeIds'>;
11
+ /** The held store: reads the entry's health token and the balancer's pin key. */
12
+ heldKeys: Pick<PinHeldKeys, 'resolve'>;
13
+ /** Registers a value for redaction before it is used. */
14
+ secret?: (value: string) => void;
15
+ fetch?: typeof fetch;
16
+ }
17
+ /**
18
+ * C2 for a request: the series of one blue/green entry, read the way a release run reads it (the entry's
19
+ * first public hostname, the colour pin headers, the health token, a 15 s timeout, a 1 MiB cap). Null for an
20
+ * org or entry that is not declared blue/green, an app without reporting, a token or pin key not held, and any
21
+ * failed read; a failure logs fixed text only. Never mints and never writes.
22
+ */
23
+ export declare function readEntryReleaseSeries(deps: EntrySeriesDeps, input: {
24
+ orgId: string;
25
+ entryKey: string;
26
+ } & ReleaseSeriesQuery): Promise<ReleaseSeries | null>;
@@ -0,0 +1,40 @@
1
+ import { sharedSecretRef } from '../org.js';
2
+ import { blueGreenSubjects, healthTokenName, pinKeyName } from './blue-green.js';
3
+ import { readSubjectSeries } from './release-host.js';
4
+ /**
5
+ * C2 for a request: the series of one blue/green entry, read the way a release run reads it (the entry's
6
+ * first public hostname, the colour pin headers, the health token, a 15 s timeout, a 1 MiB cap). Null for an
7
+ * org or entry that is not declared blue/green, an app without reporting, a token or pin key not held, and any
8
+ * failed read; a failure logs fixed text only. Never mints and never writes.
9
+ */
10
+ export async function readEntryReleaseSeries(deps, input) {
11
+ const label = `coolify:release:${input.entryKey}`;
12
+ try {
13
+ const org = await deps.orgs.get(input.orgId);
14
+ if (!org)
15
+ return null;
16
+ const ids = await deps.store.getScopeIds(input.orgId);
17
+ const subject = blueGreenSubjects(org, ids).find((s) => s.id === input.entryKey);
18
+ if (!subject)
19
+ return null;
20
+ return await readSubjectSeries({
21
+ subject,
22
+ recorded: () => ids,
23
+ healthToken: async () => (await deps.heldKeys.resolve(sharedSecretRef(org.id, healthTokenName(subject.id)))) ?? null,
24
+ secret: deps.secret ?? (() => undefined),
25
+ pinRef: sharedSecretRef(org.id, pinKeyName(subject.id)),
26
+ heldKeys: deps.heldKeys,
27
+ ...(deps.fetch ? { fetch: deps.fetch } : {}),
28
+ label,
29
+ query: {
30
+ from: input.from,
31
+ to: input.to,
32
+ ...(input.bucket !== undefined ? { bucket: input.bucket } : {}),
33
+ },
34
+ });
35
+ }
36
+ catch {
37
+ console.error(`${label}: reading the release series failed`);
38
+ return null;
39
+ }
40
+ }
@@ -9,7 +9,7 @@ export declare const PLATFORM_ARCHON_IN_APP_SCOPE_KEY = "platform_archon_in_app"
9
9
  /** The host a recorded {@link PLATFORM_ARCHON_IN_APP_SCOPE_KEY} names; `''` when none. */
10
10
  export declare function archonInAppHost(recorded: Readonly<Record<string, string>>): string;
11
11
  /** The ZITADEL calls Archon's in-app sign-in makes, on the owner company's own instance. */
12
- export type ArchonInAppZitadel = Pick<ZitadelPort, 'ensureTrustedDomain' | 'deleteTrustedDomain' | 'updateOidcAppLoginBaseUri'> & Partial<Pick<ZitadelPort, 'allowPerAppLoginBaseUri'>>;
12
+ export type ArchonInAppZitadel = Pick<ZitadelPort, 'ensureTrustedDomain' | 'deleteTrustedDomain' | 'updateOidcAppLoginBaseUri' | 'listApps'> & Partial<Pick<ZitadelPort, 'allowPerAppLoginBaseUri' | 'listOrgs' | 'listProjects'>>;
13
13
  interface ArchonInAppInput {
14
14
  zitadel: ArchonInAppZitadel;
15
15
  coolify: Pick<CoolifyPort, 'listEnvs' | 'bulkUpsertEnvs' | 'deploy'>;
@@ -1,4 +1,4 @@
1
- import { isNotFoundError, setOidcAppLoginBaseUri } from './executor.js';
1
+ import { allowPerAppLoginBaseUris, isNotFoundError, setOidcAppLoginBaseUri } from './executor.js';
2
2
  import { editSignInBindings } from './sign-in-bindings.js';
3
3
  import { SIGN_IN_BASE_PATH } from './sign-in.js';
4
4
  /**
@@ -41,7 +41,7 @@ export async function archonInAppPoint(input) {
41
41
  if (!zitadel.allowPerAppLoginBaseUri) {
42
42
  throw new Error("platform:archon: the owner company's ZITADEL port cannot allow a per-app login base URI, so Archon cannot sign in on its own address");
43
43
  }
44
- await zitadel.allowPerAppLoginBaseUri(`https://${input.loginHost}/`);
44
+ await allowPerAppLoginBaseUris(zitadel, `https://${input.loginHost}/`);
45
45
  await setOidcAppLoginBaseUri(zitadel, input.organisationId, input.projectId, input.appId, `https://${host}${SIGN_IN_BASE_PATH}/`);
46
46
  }
47
47
  /** First half of giving the address up: the client goes back to the company's login host. */
@@ -5,7 +5,7 @@ import { type StageKind } from './stage-isolation.js';
5
5
  * minted for it only. A stage key is worthless on production, and production's key never reaches stage. Nothing
6
6
  * here returns, logs or throws a secret: errors carry fixed text, ids and refs (CLAUDE.md, Platform steps).
7
7
  */
8
- export type StageService = 'files' | 'ai' | 'jobs';
8
+ export type StageService = 'files' | 'ai' | 'jobs' | 'analytics';
9
9
  export declare const STAGE_SERVICES: readonly StageService[];
10
10
  /** One credential a service shows once: the env name stage holds it under, and its value. */
11
11
  export interface StageSecret {
@@ -44,6 +44,8 @@ export interface StageServicesPort {
44
44
  files?: StageServicePort;
45
45
  ai?: StageServicePort;
46
46
  jobs?: StageServicePort;
47
+ /** The company's analytics backend: stage's own site key (environment `stage`), shown once with the mint. */
48
+ analytics?: StageServicePort;
47
49
  /** The id of production's jobs project, so stage's jobs key can be tried against it. */
48
50
  prodJobsProjectId?: string;
49
51
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { bgKey } from './blue-green.js';
3
3
  import { isAllowed } from './stage-isolation.js';
4
- export const STAGE_SERVICES = ['files', 'ai', 'jobs'];
4
+ export const STAGE_SERVICES = ['files', 'ai', 'jobs', 'analytics'];
5
5
  /** Fixed text and ids only: safe to log, record and return. */
6
6
  export class StageServiceError extends Error {
7
7
  name = 'StageServiceError';
@@ -14,6 +14,7 @@ const SERVICE_ENV = {
14
14
  evidence: ['JOBS_SCHEDULER_URL', 'JOBS_SYNC_KEY', 'JOBS_SIGNING_SECRET'],
15
15
  url: 'JOBS_SCHEDULER_URL',
16
16
  },
17
+ analytics: { evidence: ['ANALYTICS_URL', 'ANALYTICS_SITE_KEY'], url: 'ANALYTICS_URL' },
17
18
  };
18
19
  /** The fixed reasons a used service is recorded absent. */
19
20
  export const STAGE_SERVICE_ABSENT = {
@@ -1,9 +1,19 @@
1
1
  import { randomBytes, randomUUID } from 'node:crypto';
2
2
  import { EdgeApiError } from '@wtfalch/edge';
3
3
  import { uptimeArtifactUrl } from '../platform.js';
4
- import { PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_TAG_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY, PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY, PLATFORM_UPTIME_CHAT_TOKEN_REF, PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_URL_REF, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_KV_TITLE, PLATFORM_UPTIME_OPTIONAL_SECRETS, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY, uptimeAccessTokenConflict, uptimeEffectiveVars, uptimeSecretNames, uptimeVarsDigest, } from './platform-diff.js';
5
- /** The Worker's cron trigger, from Archon's `workers/uptime/manifest.json`. */
6
- const UPTIME_CRONS = ['* * * * *'];
4
+ import { PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_TAG_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY, PLATFORM_UPTIME_AUDIT_SEAL_TICK_ID_SCOPE_KEY, PLATFORM_UPTIME_AUDIT_SEAL_TICK_TOKEN_REF, PLATFORM_UPTIME_AUDIT_SEAL_TICK_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY, PLATFORM_UPTIME_CHAT_TOKEN_REF, PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_URL_REF, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_KV_TITLE, PLATFORM_UPTIME_OPTIONAL_SECRETS, PLATFORM_UPTIME_RELEASE_TICK_ID_SCOPE_KEY, PLATFORM_UPTIME_RELEASE_TICK_TOKEN_REF, PLATFORM_UPTIME_RELEASE_TICK_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY, uptimeAccessTokenConflict, uptimeEffectiveVars, uptimeSecretNames, uptimeVarsDigest, } from './platform-diff.js';
5
+ /**
6
+ * The Worker's cron triggers, in the order of Archon's `workers/uptime/manifest.json`: the probe, the
7
+ * usage tick, the charge tick, the release tick and the audit seal tick. The usage and charge ticks
8
+ * stay off because the step gives the Worker no `USAGE_TOKEN` or `CHARGE_TOKEN`.
9
+ */
10
+ const UPTIME_CRONS = [
11
+ '* * * * *',
12
+ '0 * * * *',
13
+ '15 3 * * *',
14
+ '*/10 * * * *',
15
+ '*/15 * * * *',
16
+ ];
7
17
  /** The date Archon's own Worker config used. */
8
18
  const UPTIME_COMPATIBILITY_DATE = '2026-09-27';
9
19
  /** How many times a delivery or a secrets write is tried before the step fails. */
@@ -124,6 +134,27 @@ async function attempt(what, task) {
124
134
  }
125
135
  throw new Error(`platform:uptime: ${what} failed after ${UPTIME_ATTEMPTS} attempts`);
126
136
  }
137
+ /**
138
+ * Holds a minted tick token under `ref`, the Worker token's pattern: the undelivered id is persisted
139
+ * before the value is held, the guard once it is. A guard with nothing held, or a marker without a
140
+ * guard, means the value is gone: a new one is minted and the old id dropped. Returns the held value
141
+ * and its id (the Worker secret's version label); the value goes nowhere else.
142
+ */
143
+ async function holdTickToken(context, deps, recorded, keys) {
144
+ const guard = recorded[keys.idKey];
145
+ const heldValue = guard ? await deps.heldKeys.resolve(keys.ref) : null;
146
+ if (guard && heldValue)
147
+ return { id: guard, value: heldValue };
148
+ const id = randomUUID();
149
+ const value = randomBytes(32).toString('hex');
150
+ context.recordScopeId(keys.undeliveredKey, id);
151
+ await context.persist?.();
152
+ await attempt(`holding token ${id}`, () => deps.deliverSecret(keys.ref, value));
153
+ context.recordScopeId(keys.idKey, id);
154
+ context.recordScopeId(keys.undeliveredKey, '');
155
+ await context.persist?.();
156
+ return { id, value };
157
+ }
127
158
  /** Removes an Access token edge made; one already gone is success. */
128
159
  async function removeAccessToken(access, id, what) {
129
160
  try {
@@ -165,6 +196,8 @@ export async function provisionPlatformUptime(context, deps) {
165
196
  }
166
197
  for (const ref of [
167
198
  PLATFORM_UPTIME_TOKEN_REF,
199
+ PLATFORM_UPTIME_RELEASE_TICK_TOKEN_REF,
200
+ PLATFORM_UPTIME_AUDIT_SEAL_TICK_TOKEN_REF,
168
201
  PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF,
169
202
  PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF,
170
203
  ]) {
@@ -175,6 +208,10 @@ export async function provisionPlatformUptime(context, deps) {
175
208
  PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY,
176
209
  PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY,
177
210
  PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY,
211
+ PLATFORM_UPTIME_RELEASE_TICK_ID_SCOPE_KEY,
212
+ PLATFORM_UPTIME_RELEASE_TICK_UNDELIVERED_SCOPE_KEY,
213
+ PLATFORM_UPTIME_AUDIT_SEAL_TICK_ID_SCOPE_KEY,
214
+ PLATFORM_UPTIME_AUDIT_SEAL_TICK_UNDELIVERED_SCOPE_KEY,
178
215
  ]) {
179
216
  context.recordScopeId(key, '');
180
217
  }
@@ -280,6 +317,17 @@ export async function provisionPlatformUptime(context, deps) {
280
317
  tokenId = newId;
281
318
  token = minted;
282
319
  }
320
+ // The two tick tokens, minted and held the same way, each its own value.
321
+ const releaseTick = await holdTickToken(context, deps, recorded, {
322
+ ref: PLATFORM_UPTIME_RELEASE_TICK_TOKEN_REF,
323
+ idKey: PLATFORM_UPTIME_RELEASE_TICK_ID_SCOPE_KEY,
324
+ undeliveredKey: PLATFORM_UPTIME_RELEASE_TICK_UNDELIVERED_SCOPE_KEY,
325
+ });
326
+ const auditSealTick = await holdTickToken(context, deps, recorded, {
327
+ ref: PLATFORM_UPTIME_AUDIT_SEAL_TICK_TOKEN_REF,
328
+ idKey: PLATFORM_UPTIME_AUDIT_SEAL_TICK_ID_SCOPE_KEY,
329
+ undeliveredKey: PLATFORM_UPTIME_AUDIT_SEAL_TICK_UNDELIVERED_SCOPE_KEY,
330
+ });
283
331
  // The Access service token pair, minted through edge. The id is recorded
284
332
  // and persisted before the pair is held, the guard once both are held. A
285
333
  // pair that may be lost (marker without guard, or guard with nothing held)
@@ -341,6 +389,8 @@ export async function provisionPlatformUptime(context, deps) {
341
389
  const version = `pin-${uptime.sha256.slice(0, 12)}`;
342
390
  const secrets = [
343
391
  { name: 'UPTIME_TOKEN', value: token, version: tokenId },
392
+ { name: 'RELEASE_TICK_TOKEN', value: releaseTick.value, version: releaseTick.id },
393
+ { name: 'AUDIT_SEAL_TICK_TOKEN', value: auditSealTick.value, version: auditSealTick.id },
344
394
  { name: 'CF_ACCESS_CLIENT_ID', value: clientId, version: accessId },
345
395
  { name: 'CF_ACCESS_CLIENT_SECRET', value: clientSecret, version: accessId },
346
396
  // A ping URL works as a capability: a secret, not a plain text binding.
@@ -18,7 +18,7 @@ export { type DatabaseMigration, MIGRATIONS_ASSET_NAME, WORKER_APP_MIGRATION_TIM
18
18
  * database it cannot use:
19
19
  * Access app and service token -> tunnel rule and DNS -> `<db>_rt` password -> release
20
20
  * `migrations.json` (its sha256 checked against `database.migrationsSha256`) applied with the owner credentials -> runtime role (UPDATE, DELETE and
21
- * TRUNCATE revoked on `appendOnly`) -> Hyperdrive config with the runtime credentials.
21
+ * TRUNCATE revoked on `appendOnly`, DELETE and TRUNCATE on `noDelete`) -> Hyperdrive config with the runtime credentials.
22
22
  * A failed migration throws, so the Worker is not uploaded.
23
23
  */
24
24
  /** Everything needed to open a connection to the org's database over the tunnel. */
@@ -75,6 +75,16 @@ export interface WorkerAppDatabasePort extends WorkerAppMigrationRunner<Hyperdri
75
75
  }): Promise<void>;
76
76
  /** The text of a release asset of a wtfalch repo, or `null` when the release has no such asset. */
77
77
  fetchReleaseAsset(url: string): Promise<string | null>;
78
+ /**
79
+ * After the runtime role work: the names among `tables` that `user` can still delete from or
80
+ * truncate (itself, through PUBLIC or through a role it belongs to), or that do not exist.
81
+ * Optional: without it a `noDelete` list is blocked at plan time.
82
+ */
83
+ deletableTables?(input: {
84
+ owner: HyperdriveOrigin;
85
+ user: string;
86
+ tables: string[];
87
+ }): Promise<string[]>;
78
88
  }
79
89
  /** The Hyperdrive config, `{name, id}` as JSON. No secret. */
80
90
  export declare const workerAppHyperdriveScopeKey: (entryKey: string) => string;
@@ -130,6 +140,7 @@ export declare function applyWorkerAppDatabase(deps: WorkerAppDatabaseDeps, org:
130
140
  artifactUrl: string;
131
141
  database: {
132
142
  appendOnly: string[];
143
+ noDelete?: string[];
133
144
  grants?: string[];
134
145
  migrationsSha256?: string;
135
146
  };
@@ -145,6 +145,19 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
145
145
  await context.persistScopeIds();
146
146
  },
147
147
  });
148
+ // 5a. `noDelete`: read the role's rights back, so a port that dropped the list cannot pass.
149
+ const noDelete = want.database.noDelete ?? [];
150
+ if (noDelete.length > 0) {
151
+ const read = port.deletableTables;
152
+ if (!read) {
153
+ throw new Error(`${at}: the host's database port cannot read the role's rights back (no deletableTables)`);
154
+ }
155
+ const open = await call("check the runtime role's rights", () => read.call(port, { owner: ownerOrigin, user: runtimeUser, tables: [...noDelete] }));
156
+ const named = noDelete.filter((table) => open.includes(table));
157
+ if (named.length > 0) {
158
+ throw new Error(`${at}: the runtime role can still delete from ${named.join(', ')} (or the table is missing); the Worker was not deployed`);
159
+ }
160
+ }
148
161
  // 5b. `uses.audit`: the sealing role, apart from the runtime role, after the audit tables exist.
149
162
  if (entry.uses?.audit === true && port.ensureSealerRole) {
150
163
  await ensureAuditSealer(deps, org, entry, owner.database, ownerOrigin, at, call, context, put);
@@ -67,6 +67,10 @@ export declare const WORKER_APP_AUTH_KEY_SECRET = "AUTH_APP_KEY";
67
67
  * and name are hashed: a held ref is at most 128 characters.
68
68
  */
69
69
  export declare const workerAppSecretRef: (orgId: string, entryKey: string, name: string) => string;
70
+ /** The version recorded for a `heldSecret` value: a short hash, so a changed value is delivered again. Never the value. */
71
+ export declare const heldSecretVersion: (value: string) => string;
72
+ /** `<entry key>/<NAME>` of a `heldSecret`: how the plan's readiness names it. */
73
+ export declare const heldSecretId: (entryKey: string, name: string) => string;
70
74
  /**
71
75
  * The Cloudflare calls a Worker app needs. Edge 0.13.0 has none of the Durable
72
76
  * Object, migration or container calls, so no adapter ships with this port yet
@@ -97,6 +101,11 @@ export interface WorkerAppPort {
97
101
  value: string;
98
102
  version: string;
99
103
  }[]): Promise<void>;
104
+ /**
105
+ * Sets the script's cron triggers to exactly `crons`; an empty list clears them. Safe to repeat.
106
+ * Edge's `client.platform.workers.setSchedules(name, { crons })`.
107
+ */
108
+ setSchedules(name: string, crons: readonly string[]): Promise<void>;
100
109
  /** A no-op when the secret is gone. */
101
110
  deleteSecret(name: string, secretName: string): Promise<void>;
102
111
  /** Creates or updates the container application of `className` on the script; returns its id. */
@@ -155,14 +164,21 @@ export interface WorkerAppWant {
155
164
  /** `workerApp.database`: the Hyperdrive binding and the tables the runtime role may only append to, sorted. */
156
165
  database?: {
157
166
  appendOnly: string[];
167
+ noDelete?: string[];
158
168
  grants?: string[];
159
169
  migrationsSha256?: string;
160
170
  };
161
- /** Secret names and where each comes from, sorted. */
171
+ /** `workerApp.triggers.crons`, sorted. Absent when none, so a Worker without cron keeps the marker it already has. */
172
+ crons?: string[];
173
+ /**
174
+ * Secret names and where each comes from, sorted. A `heldSecret` is listed only while a value is held,
175
+ * with the version of that value (a hash, never the value); one with no value is not here.
176
+ */
162
177
  secrets: {
163
178
  name: string;
164
- source: 'generated' | 'keysCustody';
179
+ source: 'generated' | 'keysCustody' | 'heldSecret';
165
180
  hostId?: string;
181
+ version?: string;
166
182
  }[];
167
183
  }
168
184
  /** The zone apex the entry's route lives in. */
@@ -174,8 +190,11 @@ export declare const workerAppApex: (org: DeclaredOrg, entry?: HostingEntry) =>
174
190
  export declare function wantedWorkerApp(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>, input: {
175
191
  issuer: string | null;
176
192
  keysUrl?: string;
193
+ /** `<entry key>/<NAME>` -> version of the value held now, for each `heldSecret` that has one. */
194
+ heldSecrets?: Readonly<Record<string, string>>;
177
195
  }): {
178
196
  want: WorkerAppWant;
197
+ unset: string[];
179
198
  } | {
180
199
  missing: string[];
181
200
  };
@@ -201,6 +220,8 @@ export interface WorkerAppPlanInput {
201
220
  canHoldSecrets: boolean;
202
221
  /** The sign-in issuer for `authEnv`. */
203
222
  issuer: string | null;
223
+ /** `<entry key>/<NAME>` -> version of the value held now, for each `heldSecret` that has one (names only, never a value). */
224
+ heldSecrets?: Readonly<Record<string, string>>;
204
225
  /** The platform's Cloudflare account id: a container image must live in its registry. */
205
226
  registryAccountId?: string;
206
227
  /**
@@ -214,6 +235,8 @@ export interface WorkerAppPlanInput {
214
235
  tunnelReady: boolean;
215
236
  /** The executor has Access, tunnel, DNS and zone ports. */
216
237
  accessReady: boolean;
238
+ /** The port has `deletableTables`, so a `noDelete` list can be proven after the role work. */
239
+ noDeleteCheck?: boolean;
217
240
  };
218
241
  }
219
242
  /** Every entry key with a deployed or half-deployed Worker app recorded. */
@@ -245,6 +268,8 @@ export interface WorkerAppDeps {
245
268
  };
246
269
  /** `workerApp.database`: the Hyperdrive port and the route's Cloudflare ports. */
247
270
  database?: WorkerAppDatabaseDeps;
271
+ /** Registers a value for redaction before anything else can see it. */
272
+ secret?: (value: string) => void;
248
273
  /** Test hook: the timeout, in ms. */
249
274
  timeoutMs?: number;
250
275
  }