@wtfalch/foundry 0.46.0 → 0.48.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 (59) hide show
  1. package/dist/build-json.d.ts +28 -0
  2. package/dist/build-json.js +95 -0
  3. package/dist/index.d.ts +10 -4
  4. package/dist/index.js +6 -2
  5. package/dist/migrations/0058_app_requests.sql +32 -0
  6. package/dist/migrations/0059_org_builtin_blue_green.sql +9 -0
  7. package/dist/org.d.ts +47 -5
  8. package/dist/org.js +176 -128
  9. package/dist/plan/archon-blue-green.js +15 -1
  10. package/dist/plan/blue-green-apply.d.ts +35 -0
  11. package/dist/plan/blue-green-apply.js +167 -16
  12. package/dist/plan/blue-green.d.ts +97 -2
  13. package/dist/plan/blue-green.js +214 -13
  14. package/dist/plan/database-dump.d.ts +37 -0
  15. package/dist/plan/database-dump.js +101 -0
  16. package/dist/plan/diff.js +9 -5
  17. package/dist/plan/executor.d.ts +48 -0
  18. package/dist/plan/executor.js +87 -28
  19. package/dist/plan/health-targets.js +1 -2
  20. package/dist/plan/hostnames.d.ts +2 -2
  21. package/dist/plan/hostnames.js +6 -6
  22. package/dist/plan/index.d.ts +3 -1
  23. package/dist/plan/index.js +5 -1
  24. package/dist/plan/locked-hostnames.d.ts +1 -0
  25. package/dist/plan/locked-hostnames.js +1 -0
  26. package/dist/plan/plan-apply.d.ts +30 -1
  27. package/dist/plan/plan-apply.js +86 -8
  28. package/dist/plan/platform-diff.js +2 -1
  29. package/dist/plan/platform-providers.js +5 -0
  30. package/dist/plan/providers-readers.js +15 -4
  31. package/dist/plan/proxy-labels.d.ts +13 -1
  32. package/dist/plan/proxy-labels.js +26 -3
  33. package/dist/plan/release-host.d.ts +1 -1
  34. package/dist/plan/release-host.js +36 -14
  35. package/dist/plan/stage-env.d.ts +38 -0
  36. package/dist/plan/stage-env.js +52 -0
  37. package/dist/plan/stage-guards.d.ts +104 -0
  38. package/dist/plan/stage-guards.js +149 -0
  39. package/dist/plan/stage-isolation.d.ts +57 -0
  40. package/dist/plan/stage-isolation.js +248 -0
  41. package/dist/plan/worker-app.js +3 -1
  42. package/dist/refuse-org.d.ts +7 -0
  43. package/dist/refuse-org.js +12 -0
  44. package/dist/release/decide.d.ts +5 -0
  45. package/dist/release/decide.js +10 -6
  46. package/dist/release/health.d.ts +6 -1
  47. package/dist/release/health.js +10 -2
  48. package/dist/release/run.d.ts +2 -0
  49. package/dist/release/run.js +2 -1
  50. package/dist/requested-changes.d.ts +50 -4
  51. package/dist/requested-changes.js +9 -1
  52. package/dist/store/postgres.d.ts +5 -1
  53. package/dist/store/postgres.js +86 -8
  54. package/dist/store/schema.d.ts +111 -1
  55. package/dist/store/schema.js +7 -0
  56. package/dist/store.d.ts +24 -1
  57. package/dist/worker-app-config.d.ts +8 -2
  58. package/dist/worker-app-config.js +20 -6
  59. package/package.json +1 -1
@@ -280,12 +280,23 @@ async function withBouleEnvConfig(client, resource, recorded, liveKeys) {
280
280
  // Boule must hold too. In memory only, compared by `planBoule` and never
281
281
  // written to a step, description or log.
282
282
  const orgId = recorded[resource.key]?.slice('boule:'.length);
283
- const appUuid = Object.entries(recorded).find(([, value]) => value === `app:${orgId}:${APP_ENTRY_KEY}`)?.[0];
283
+ // The token sits on the org's main app, whatever its key; the reader does not know the
284
+ // declaration, so it tries the `app` entry first and then each other recorded app.
285
+ const appPrefix = `app:${orgId}:`;
286
+ const appUuids = Object.entries(recorded)
287
+ .filter(([, value]) => value.startsWith(appPrefix))
288
+ .sort(([, a], [, b]) => Number(b === `${appPrefix}${APP_ENTRY_KEY}`) - Number(a === `${appPrefix}${APP_ENTRY_KEY}`))
289
+ .map(([uuid]) => uuid);
284
290
  // A recorded app that is not in the live listing is gone: no app token, and
285
291
  // no read of its envs. A listed app whose read fails fails like any read.
286
- const appToken = appUuid && liveKeys.has(appUuid)
287
- ? (await client.listEnvs(appUuid)).find((e) => !e.is_preview && e.key === BOULE_MAIL_TOKEN_ENV)?.value
288
- : undefined;
292
+ let appToken;
293
+ for (const appUuid of appUuids) {
294
+ if (!liveKeys.has(appUuid))
295
+ continue;
296
+ appToken = (await client.listEnvs(appUuid)).find((e) => !e.is_preview && e.key === BOULE_MAIL_TOKEN_ENV)?.value;
297
+ if (appToken)
298
+ break;
299
+ }
289
300
  return {
290
301
  ...resource,
291
302
  config: { ...resource.config, env, ...(appToken ? { appMailToken: appToken } : {}) },
@@ -51,7 +51,19 @@ export declare function entryRoutes(org: DeclaredOrg, recorded?: Readonly<Record
51
51
  hostname: string;
52
52
  service: string;
53
53
  }>;
54
- /** The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}. */
54
+ /**
55
+ * The tunnel routes of the company's Boule and Agora that run behind a balancer: their names go to it once the
56
+ * structure on the primary is applied. Until then (and for an app that does not declare blue/green) a name keeps
57
+ * the Coolify proxy origin it had, so none appears here.
58
+ */
59
+ export declare function builtinRoutes(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): Array<{
60
+ hostname: string;
61
+ service: string;
62
+ }>;
63
+ /**
64
+ * The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}.
65
+ * A built-in app's balancer route is added only when there is one, so a company without it keeps its marker.
66
+ */
55
67
  export declare function entryRoutesMarker(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): string;
56
68
  /**
57
69
  * A short digest of the spec; the same spec always gives the same hash. The token is left out: the
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { PRIMARY_SERVER_SCOPE_KEY, balancerApplied, lbAppName, liveAppNameFor, } from './blue-green.js';
2
+ import { BUILTIN_APPS, PRIMARY_SERVER_SCOPE_KEY, balancerApplied, builtinSubject, lbAppName, lbOrigin, liveAppNameFor, } from './blue-green.js';
3
3
  import { entryHostnames, wildcardHostname } from './hostnames.js';
4
4
  import { NO_DOMAIN_ZONE } from './own-hostnames.js';
5
5
  import { appNameFor } from './platform-diff.js';
@@ -85,9 +85,32 @@ export function entryRoutes(org, recorded = {}) {
85
85
  routes.push(wildcard);
86
86
  return routes;
87
87
  }
88
- /** The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}. */
88
+ /**
89
+ * The tunnel routes of the company's Boule and Agora that run behind a balancer: their names go to it once the
90
+ * structure on the primary is applied. Until then (and for an app that does not declare blue/green) a name keeps
91
+ * the Coolify proxy origin it had, so none appears here.
92
+ */
93
+ export function builtinRoutes(org, recorded = {}) {
94
+ const routes = [];
95
+ for (const app of BUILTIN_APPS) {
96
+ const subject = builtinSubject(org, app, recorded);
97
+ if (!subject || !balancerApplied(subject.id, recorded, recorded[PRIMARY_SERVER_SCOPE_KEY])) {
98
+ continue;
99
+ }
100
+ for (const hostname of subject.hostnames) {
101
+ routes.push({ hostname, service: lbOrigin(subject) });
102
+ }
103
+ }
104
+ return routes;
105
+ }
106
+ /**
107
+ * The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}.
108
+ * A built-in app's balancer route is added only when there is one, so a company without it keeps its marker.
109
+ */
89
110
  export function entryRoutesMarker(org, recorded = {}) {
90
- return JSON.stringify(entryRoutes(org, recorded));
111
+ const builtin = builtinRoutes(org, recorded);
112
+ const routes = entryRoutes(org, recorded);
113
+ return JSON.stringify(builtin.length > 0 ? [...routes, ...builtin] : routes);
91
114
  }
92
115
  /**
93
116
  * A short digest of the spec; the same spec always gives the same hash. The token is left out: the
@@ -21,7 +21,7 @@ export interface ReleaseHost {
21
21
  export interface ReleaseHostInput {
22
22
  subject: BlueGreenSubject;
23
23
  context: ReleaseContext;
24
- coolify: Pick<CoolifyPort, 'getApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'deployAndWait' | 'raw' | 'listStorages' | 'createFileStorage' | 'updateFileStorage'> & {
24
+ coolify: Pick<CoolifyPort, 'getApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deployAndWait' | 'raw' | 'listStorages' | 'createFileStorage' | 'updateFileStorage'> & {
25
25
  listApplicationDeployments?: CoolifyPort['listApplicationDeployments'];
26
26
  };
27
27
  /** Reads the entry's health token (`sharedSecretRef(org.id, healthTokenName(id))`). Never mints. */
@@ -1,6 +1,6 @@
1
1
  import { checkColorAddress, readReleaseHealth } from '../release/health.js';
2
2
  import { runRelease } from '../release/run.js';
3
- import { applyBlueGreenWeights } from './blue-green-apply.js';
3
+ import { applyBlueGreenWeights, copiedEnv } from './blue-green-apply.js';
4
4
  import { DEFAULT_WEIGHTS, LB_COLOR_HEADER, LB_PIN_HEADER, bgKey, formatWeights, parseWeights, reportingEnv, } from './blue-green.js';
5
5
  import { waitForNewDeployment } from './deploy-health.js';
6
6
  import { isNotFoundError } from './executor.js';
@@ -19,21 +19,23 @@ export function createReleaseHost(input) {
19
19
  const id = subject.id;
20
20
  const recorded = () => context.recordedIds();
21
21
  /** Every Coolify app of `color`, one per server that has the structure applied. */
22
- const colorApps = (color) => {
22
+ const colorAppsOnServers = (color) => {
23
23
  const ids = recorded();
24
24
  const apps = [];
25
25
  if (color === 'blue')
26
- apps.push(input.primaryBlueAppId);
26
+ apps.push({ appId: input.primaryBlueAppId, server: input.primaryUuid });
27
27
  const prefix = `bg.${id}.app.`;
28
28
  for (const key of Object.keys(ids).sort()) {
29
29
  if (!key.startsWith(prefix) || !key.endsWith(`.${color}`))
30
30
  continue;
31
31
  const value = ids[key];
32
+ const server = key.slice(prefix.length, key.length - color.length - 1);
32
33
  if (value)
33
- apps.push(value);
34
+ apps.push({ appId: value, server });
34
35
  }
35
36
  return apps;
36
37
  };
38
+ const colorApps = (color) => colorAppsOnServers(color).map((a) => a.appId);
37
39
  const primaryColorApp = (color) => color === 'blue' ? input.primaryBlueAppId : recorded()[bgKey.app(id, input.primaryUuid, color)];
38
40
  /**
39
41
  * Where the runner reaches a color: the entry's first public hostname, pinned to the color by the balancer's
@@ -66,21 +68,39 @@ export function createReleaseHost(input) {
66
68
  const deployTo = async (color, digest) => {
67
69
  if (!COMMIT.test(digest))
68
70
  throw new Error(`${input.label}: the release is not a built commit`);
69
- const token = await input.healthToken();
70
- if (!token)
71
- throw new Error(`${input.label}: the health token is not held`);
72
- input.secret(token);
73
- const apps = colorApps(color);
71
+ const token = subject.reporting ? await input.healthToken() : null;
72
+ if (subject.reporting) {
73
+ if (!token)
74
+ throw new Error(`${input.label}: the health token is not held`);
75
+ input.secret(token);
76
+ }
77
+ const apps = colorAppsOnServers(color);
74
78
  if (apps.length === 0)
75
79
  throw new Error(`${input.label}: ${color} has no app recorded`);
76
- for (const appId of apps) {
80
+ for (const { appId, server } of apps) {
77
81
  await input.renew();
78
82
  await coolify.updateApplication(appId, { git_commit_sha: digest });
83
+ // The color takes blue's env as it is now, so a change made after the structure apply (sign-in, mail,
84
+ // credentials) reaches it before it serves. Blue on the primary is the source itself.
85
+ let source = [];
86
+ if (appId !== input.primaryBlueAppId) {
87
+ try {
88
+ source = await coolify.listEnvs(input.primaryBlueAppId);
89
+ }
90
+ catch {
91
+ throw new Error(`${input.label}: reading the env of the blue app failed`);
92
+ }
93
+ }
94
+ const refreshed = copiedEnv({ label: input.label, secret: input.secret }, subject, source, {
95
+ uuid: server,
96
+ primaryUuid: input.primaryUuid,
97
+ });
98
+ const reporting = token ? reportingEnv({ color, release: digest, healthToken: token }) : [];
79
99
  try {
80
- await coolify.bulkUpsertEnvs(appId, reportingEnv({ color, release: digest, healthToken: token }).map((e) => ({
81
- ...e,
82
- literal: true,
83
- })));
100
+ const envs = [...refreshed, ...reporting];
101
+ if (envs.length > 0) {
102
+ await coolify.bulkUpsertEnvs(appId, envs.map((e) => ({ ...e, literal: true })));
103
+ }
84
104
  }
85
105
  catch {
86
106
  // A Coolify error can echo the request body, which holds the token.
@@ -128,6 +148,7 @@ export function createReleaseHost(input) {
128
148
  address: await checkColorAddress({
129
149
  ...addressOf(color),
130
150
  healthPath: subject.healthPath,
151
+ expectJson: subject.reporting && subject.healthPath === '/api/health',
131
152
  ...(input.fetch ? { fetchImpl: input.fetch } : {}),
132
153
  }),
133
154
  };
@@ -221,6 +242,7 @@ export async function runSubjectRelease(input) {
221
242
  entry: subject.config,
222
243
  now: input.now,
223
244
  deps: input.host.deps,
245
+ ...(subject.reporting ? {} : { hardOnly: true }),
224
246
  });
225
247
  for (const [key, value] of Object.entries(result.writes)) {
226
248
  if (value === null)
@@ -0,0 +1,38 @@
1
+ import { type StageKind } from './stage-isolation.js';
2
+ export interface StageEnvInput {
3
+ kind: StageKind;
4
+ /** Where the stage app is reached: its own container address, since stage has no hostname. */
5
+ appUrl: string;
6
+ logDir: string;
7
+ /** The stage database's URLs, already minted for stage. */
8
+ database: ReadonlyArray<{
9
+ key: string;
10
+ value: string;
11
+ }>;
12
+ /** The env of the live app. Read for the names it uses, and for the values on `SHARED_WITH_PROD`; nothing else is copied. */
13
+ prod: ReadonlyArray<{
14
+ key: string;
15
+ value?: string;
16
+ is_preview?: boolean;
17
+ }>;
18
+ /** The env stage holds now: a minted secret is kept, so a second apply does not rotate it. */
19
+ existing: ReadonlyArray<{
20
+ key: string;
21
+ value?: string;
22
+ is_preview?: boolean;
23
+ }>;
24
+ mint: () => string;
25
+ }
26
+ export interface StageEnv {
27
+ env: Array<{
28
+ key: string;
29
+ value: string;
30
+ }>;
31
+ /** Names the live app uses that stage does not get yet. A service is either in `env` or here. */
32
+ absent: string[];
33
+ }
34
+ /**
35
+ * The env of a stage app, built from the kind's allowlist and never from a copy of the live app's env. Every
36
+ * name is allowed and none is denied, or this throws a fixed text with the name.
37
+ */
38
+ export declare function buildStageEnv(input: StageEnvInput): StageEnv;
@@ -0,0 +1,52 @@
1
+ import { STAGE_ALLOWLIST, isAllowed, isDenied, isSharedWithProd, } from './stage-isolation.js';
2
+ /** Prod names whose stage value waits for the stage's own organisation or project: absent until then. */
3
+ const WAITS_FOR_STAGE_SERVICE = new Set([
4
+ 'FILES_URL',
5
+ 'AI_URL',
6
+ 'JOBS_SCHEDULER_URL',
7
+ 'ANALYTICS_URL',
8
+ ]);
9
+ /** Secrets stage mints for itself, when the app uses them. A copy of prod's value would be a prod credential. */
10
+ const MINTED = ['AUTH_COOKIE_SECRET', 'KEYSTORE_KEK'];
11
+ /**
12
+ * The env of a stage app, built from the kind's allowlist and never from a copy of the live app's env. Every
13
+ * name is allowed and none is denied, or this throws a fixed text with the name.
14
+ */
15
+ export function buildStageEnv(input) {
16
+ const { kind } = input;
17
+ const live = (list) => new Map(list
18
+ .filter((e) => !e.is_preview && e.value !== undefined)
19
+ .map((e) => [e.key, e.value]));
20
+ const prod = live(input.prod);
21
+ const existing = live(input.existing);
22
+ const env = new Map();
23
+ const set = (key, value) => {
24
+ if (isDenied(kind, key) || !isAllowed(kind, key)) {
25
+ throw new Error(`stage env: ${key} is not allowed for a ${kind} stage`);
26
+ }
27
+ env.set(key, value);
28
+ };
29
+ set('LOG_DIR', input.logDir);
30
+ if (isAllowed(kind, 'APP_URL'))
31
+ set('APP_URL', input.appUrl);
32
+ if (isAllowed(kind, 'APP_ENVIRONMENT'))
33
+ set('APP_ENVIRONMENT', 'stage');
34
+ for (const { key, value } of input.database)
35
+ set(key, value);
36
+ for (const [key, value] of prod) {
37
+ if (!env.has(key) &&
38
+ isSharedWithProd(key) &&
39
+ isAllowed(kind, key) &&
40
+ !WAITS_FOR_STAGE_SERVICE.has(key)) {
41
+ set(key, value);
42
+ }
43
+ }
44
+ for (const key of MINTED) {
45
+ if (prod.has(key) && isAllowed(kind, key))
46
+ set(key, existing.get(key) ?? input.mint());
47
+ }
48
+ const absent = STAGE_ALLOWLIST[kind]
49
+ .filter((name) => !name.endsWith('*') && prod.has(name) && !env.has(name))
50
+ .sort();
51
+ return { env: [...env].map(([key, value]) => ({ key, value })), absent };
52
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The guards that must hold before stage gets an exact copy of production (campaign T3 section 5, G2 and G3).
3
+ * Each is checked here and recorded under a `bg.<id>.…` scope key only after it was verified live. Nothing in
4
+ * this file returns, logs or throws a value: errors and results carry fixed text and names.
5
+ */
6
+ /** Milliseconds one probe may take. A probe that does not answer in time is not a rejection. */
7
+ export declare const STAGE_PROBE_TIMEOUT_MS = 10000;
8
+ /**
9
+ * The calls the host makes to prove a stage credential does not work against production. Every method answers
10
+ * `true` when production REFUSED the credential, `false` when it accepted it. A method the host does not
11
+ * implement leaves that probe "not run". No method ever returns a value.
12
+ */
13
+ export interface StageProbePort {
14
+ /** Logs in to the production database host with the stage role and password. */
15
+ databaseRejects?(input: {
16
+ host: string;
17
+ port: number;
18
+ database: string;
19
+ user: string;
20
+ password: string;
21
+ }): Promise<boolean>;
22
+ /** Uses the stage files key on production's organisation. */
23
+ filesRejects?(input: {
24
+ url: string;
25
+ apiKey: string;
26
+ organisationId: string;
27
+ }): Promise<boolean>;
28
+ /** Uses the stage ai key on production's organisation. */
29
+ aiRejects?(input: {
30
+ url: string;
31
+ apiKey: string;
32
+ organisationId: string;
33
+ }): Promise<boolean>;
34
+ /** Uses the stage jobs key on production's project. */
35
+ jobsRejects?(input: {
36
+ url: string;
37
+ syncKey: string;
38
+ projectId: string;
39
+ }): Promise<boolean>;
40
+ }
41
+ /** One probe. `run` is `null` when the host has no port for it: the probe is then reported as not run. */
42
+ export interface StageProbe {
43
+ what: string;
44
+ /** `true`: production rejected the stage credential. */
45
+ run: (() => Promise<boolean>) | null;
46
+ }
47
+ export interface StageProbeInput {
48
+ port: StageProbePort | undefined;
49
+ /** The env stage will hold. */
50
+ stage: ReadonlyMap<string, string>;
51
+ /** The env of the live app. */
52
+ prod: ReadonlyMap<string, string>;
53
+ /** Whether stage has a database: if no database probe can be built, one is kept as not run. */
54
+ database?: boolean;
55
+ }
56
+ /**
57
+ * The probes for what stage holds: its database role against production's database host, and its files, ai and
58
+ * jobs keys against production's organisation or project, each only where stage has the key and production
59
+ * names the target. A probe whose port method is missing is kept with `run: null`.
60
+ */
61
+ export declare function buildStageProbes(input: StageProbeInput): StageProbe[];
62
+ export interface StageProbeResult {
63
+ /** Names of the probes production rejected. */
64
+ rejected: string[];
65
+ /** Names of the probes that were accepted, threw or timed out: the apply fails on these. */
66
+ failed: string[];
67
+ /** Names of the probes with no port: reported as not run, never as passed. */
68
+ notRun: string[];
69
+ }
70
+ /** Runs every probe once, each under a timeout. A throw and a timeout count as failed, never as rejected. */
71
+ export declare function runStageProbes(probes: readonly StageProbe[], options?: {
72
+ timeoutMs?: number;
73
+ }): Promise<StageProbeResult>;
74
+ /**
75
+ * What `bg.<id>.stageprobes` holds: `ok:<hash>` when every probe ran and was rejected (the hash covers the
76
+ * stage env names, the sha256 of each stage secret and URL value, and the probe names, so a changed stage
77
+ * credential invalidates it), `notrun:<names>` when a probe had no port, `failed:<names>` when one was accepted.
78
+ */
79
+ export declare function stageProbesRecord(result: StageProbeResult, isolationHash: string, stage: ReadonlyMap<string, string>): {
80
+ value: string;
81
+ ok: boolean;
82
+ };
83
+ /** The Access gate stage's host sits behind: made once, staff only, recorded as `bg.<id>.stagegate`. */
84
+ export interface StageGate {
85
+ /** The stage host the gate covers. */
86
+ hostname: string;
87
+ /** Makes the gate (no bypass and no service-token rule) and returns its id. */
88
+ create: () => Promise<{
89
+ id: string;
90
+ }>;
91
+ }
92
+ export interface StageGuardDeps {
93
+ probes?: StageProbePort;
94
+ gate?: StageGate;
95
+ }
96
+ /** The key of the stage gate in the company (a DNS label, like a declared gate's). */
97
+ export declare const stageGateKey: (id: string) => string;
98
+ /** The hostname stage is served at: under the company's domain, never a name production uses. */
99
+ export declare const stageHostname: (domain: string, id: string) => string;
100
+ /** Why stage guards are not ready, or `[]`. Names only; the copy slice calls this before it reads production. */
101
+ export declare function stageGuardsReady(recorded: Readonly<Record<string, string>>, id: string): {
102
+ ready: boolean;
103
+ missing: string[];
104
+ };
@@ -0,0 +1,149 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { bgKey } from './blue-green.js';
3
+ import { envClass } from './stage-isolation.js';
4
+ /**
5
+ * The guards that must hold before stage gets an exact copy of production (campaign T3 section 5, G2 and G3).
6
+ * Each is checked here and recorded under a `bg.<id>.…` scope key only after it was verified live. Nothing in
7
+ * this file returns, logs or throws a value: errors and results carry fixed text and names.
8
+ */
9
+ /** Milliseconds one probe may take. A probe that does not answer in time is not a rejection. */
10
+ export const STAGE_PROBE_TIMEOUT_MS = 10_000;
11
+ const DB_URL = /^[a-z][a-z0-9+.-]*:\/\/([^:@/]*):([^@/]*)@([^/:?#]+)(?::(\d+))?\/([^?#]*)/i;
12
+ function parseDatabaseUrl(name, value) {
13
+ const match = DB_URL.exec(value);
14
+ if (!match)
15
+ throw new Error(`stage probes: ${name} is not a connection URL`);
16
+ return {
17
+ user: decodeURIComponent(match[1]),
18
+ password: decodeURIComponent(match[2]),
19
+ host: match[3],
20
+ port: match[4] ? Number(match[4]) : 5432,
21
+ database: match[5],
22
+ };
23
+ }
24
+ /**
25
+ * The probes for what stage holds: its database role against production's database host, and its files, ai and
26
+ * jobs keys against production's organisation or project, each only where stage has the key and production
27
+ * names the target. A probe whose port method is missing is kept with `run: null`.
28
+ */
29
+ export function buildStageProbes(input) {
30
+ const { port, stage, prod, database } = input;
31
+ const probes = [];
32
+ const stageUrl = stage.get('DATABASE_URL');
33
+ const prodUrl = prod.get('DATABASE_URL_OWNER') ?? prod.get('DATABASE_URL');
34
+ if (stageUrl && prodUrl) {
35
+ const from = parseDatabaseUrl('DATABASE_URL', stageUrl);
36
+ const to = parseDatabaseUrl('the live database URL', prodUrl);
37
+ const call = port?.databaseRejects?.bind(port);
38
+ probes.push({
39
+ what: 'database:stage-role-on-prod-host',
40
+ run: call
41
+ ? () => call({
42
+ host: to.host,
43
+ port: to.port,
44
+ database: to.database,
45
+ user: from.user,
46
+ password: from.password,
47
+ })
48
+ : null,
49
+ });
50
+ }
51
+ if (database && !(stageUrl && prodUrl)) {
52
+ // Stage has a database but a URL could not be read: the guard is not run, never passed.
53
+ probes.push({ what: 'database', run: null });
54
+ }
55
+ for (const [what, urlName, keyName, orgName, method] of [
56
+ ['files:stage-key-on-prod-org', 'FILES_URL', 'FILES_API_KEY', 'FILES_ORGANISATION_ID', 'files'],
57
+ ['ai:stage-key-on-prod-org', 'AI_URL', 'AI_API_KEY', 'AI_ORGANISATION_ID', 'ai'],
58
+ ]) {
59
+ const url = stage.get(urlName);
60
+ const apiKey = stage.get(keyName);
61
+ const organisationId = prod.get(orgName);
62
+ if (!url || !apiKey || !organisationId)
63
+ continue;
64
+ const call = (method === 'files' ? port?.filesRejects : port?.aiRejects)?.bind(port);
65
+ probes.push({
66
+ what,
67
+ run: call ? () => call({ url, apiKey, organisationId }) : null,
68
+ });
69
+ }
70
+ const jobsUrl = stage.get('JOBS_SCHEDULER_URL');
71
+ const syncKey = stage.get('JOBS_SYNC_KEY');
72
+ const projectId = prod.get('JOBS_PROJECT_ID');
73
+ if (jobsUrl && syncKey && projectId) {
74
+ const call = port?.jobsRejects?.bind(port);
75
+ probes.push({
76
+ what: 'jobs:stage-key-on-prod-project',
77
+ run: call ? () => call({ url: jobsUrl, syncKey, projectId }) : null,
78
+ });
79
+ }
80
+ return probes;
81
+ }
82
+ /** Runs every probe once, each under a timeout. A throw and a timeout count as failed, never as rejected. */
83
+ export async function runStageProbes(probes, options = {}) {
84
+ const timeoutMs = options.timeoutMs ?? STAGE_PROBE_TIMEOUT_MS;
85
+ const out = { rejected: [], failed: [], notRun: [] };
86
+ for (const probe of probes) {
87
+ if (!probe.run) {
88
+ out.notRun.push(probe.what);
89
+ continue;
90
+ }
91
+ let timer;
92
+ try {
93
+ const rejected = await Promise.race([
94
+ probe.run(),
95
+ new Promise((_, reject) => {
96
+ timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
97
+ }),
98
+ ]);
99
+ (rejected === true ? out.rejected : out.failed).push(probe.what);
100
+ }
101
+ catch {
102
+ // The cause can echo the credential; the name is all the report needs.
103
+ out.failed.push(probe.what);
104
+ }
105
+ finally {
106
+ if (timer)
107
+ clearTimeout(timer);
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+ const sha256 = (value) => createHash('sha256').update(value).digest('hex');
113
+ /**
114
+ * What `bg.<id>.stageprobes` holds: `ok:<hash>` when every probe ran and was rejected (the hash covers the
115
+ * stage env names, the sha256 of each stage secret and URL value, and the probe names, so a changed stage
116
+ * credential invalidates it), `notrun:<names>` when a probe had no port, `failed:<names>` when one was accepted.
117
+ */
118
+ export function stageProbesRecord(result, isolationHash, stage) {
119
+ if (result.failed.length > 0)
120
+ return { value: `failed:${result.failed.join(',')}`, ok: false };
121
+ if (result.notRun.length > 0)
122
+ return { value: `notrun:${result.notRun.join(',')}`, ok: false };
123
+ const credentials = [...stage]
124
+ .filter(([name]) => envClass(name) === 'secret' || envClass(name) === 'url')
125
+ .map(([name, value]) => `${name}=${sha256(value)}`)
126
+ .sort();
127
+ return {
128
+ value: `ok:${sha256(`${isolationHash}:${result.rejected.join(',')}:${credentials.join(',')}`)}`,
129
+ ok: true,
130
+ };
131
+ }
132
+ /** The key of the stage gate in the company (a DNS label, like a declared gate's). */
133
+ export const stageGateKey = (id) => `stage-${id}`;
134
+ /** The hostname stage is served at: under the company's domain, never a name production uses. */
135
+ export const stageHostname = (domain, id) => `stage-${id}.${domain}`;
136
+ /** Why stage guards are not ready, or `[]`. Names only; the copy slice calls this before it reads production. */
137
+ export function stageGuardsReady(recorded, id) {
138
+ const missing = [];
139
+ if (!recorded[bgKey.stageIsolation(id)])
140
+ missing.push('stageisolation');
141
+ if (!recorded[bgKey.stageGate(id)])
142
+ missing.push('stagegate');
143
+ if (!recorded[bgKey.stageProbes(id)]?.startsWith('ok:'))
144
+ missing.push('stageprobes');
145
+ // An older ok: record outlives a later block; the stage state says what the last apply found.
146
+ if (recorded[bgKey.stageState(id)] !== 'ok')
147
+ missing.push('stagestate');
148
+ return { ready: missing.length === 0, missing };
149
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The stage-isolation check (campaign T3 section 4). Stage must hold no credential that reaches production,
3
+ * so this runs before stage gets any env and again when the plan is made. It returns violation names only:
4
+ * a violation names an env var or a resource kind, never a value.
5
+ */
6
+ export type StageKind = 'boule' | 'agora' | 'archon' | 'customer';
7
+ export type EnvClass = 'secret' | 'url' | 'id' | 'literal';
8
+ export interface SurfaceEnv {
9
+ name: string;
10
+ class: EnvClass;
11
+ /** The value, where the caller holds it. */
12
+ value?: string;
13
+ /** A reference to the value, where the caller holds only that (plan time). */
14
+ ref?: string;
15
+ }
16
+ /** What one side (production or stage) holds. */
17
+ export interface Surface {
18
+ env: SurfaceEnv[];
19
+ /** Every name the side can collide on: app, database and role names, hostnames, container names. */
20
+ resources: Record<string, string>;
21
+ /** Internal hosts a stage URL must not name (prod database container, sidecar, balancer, platform apps). */
22
+ internalHosts?: readonly string[];
23
+ }
24
+ /** The class of an env name, from its spelling. */
25
+ export declare function envClass(name: string): EnvClass;
26
+ /** Env names (or `PREFIX_*`) each kind of stage app may hold. A name not here is refused. */
27
+ export declare const STAGE_ALLOWLIST: Record<StageKind, readonly string[]>;
28
+ /** Whether `name` is on the deny list of `kind`. */
29
+ export declare function isDenied(kind: StageKind, name: string): boolean;
30
+ /** Whether `name` is on the allowlist of `kind`. Mail names are checked by A5 only. */
31
+ export declare function isAllowed(kind: StageKind, name: string): boolean;
32
+ /**
33
+ * Env that stage holds with the same value as production, on purpose. Each entry says why that cannot write
34
+ * to production. A name not here must differ from every production value.
35
+ */
36
+ export declare const SHARED_WITH_PROD: Readonly<Record<string, string>>;
37
+ /** Whether `name` may equal production's value. */
38
+ export declare const isSharedWithProd: (name: string) => boolean;
39
+ export interface StageIsolationInput {
40
+ kind: StageKind;
41
+ prod: Surface;
42
+ stage: Surface;
43
+ /** The mail env stage is allowed to hold: the sink's wiring. Empty: stage holds no mail env. */
44
+ sinkMail?: Readonly<Record<string, string>>;
45
+ }
46
+ /**
47
+ * The violations of stage isolation, as `<code>:<name>`; empty when stage is isolated. Names only: an env
48
+ * name or a resource kind, never a value.
49
+ * - A1 allowlist: `env_not_allowed`. A2 deny list: `env_denied`.
50
+ * - A3 values: `value_shared` (a secret, url or id equals a production value), `shared_differs` (a name on
51
+ * {@link SHARED_WITH_PROD} differs from production's), `live_mode_key`.
52
+ * - A4 names: `resource_shared`, `host_is_prod_internal`.
53
+ * - A5 mail: `mail_not_sink`, `mail_token`.
54
+ */
55
+ export declare function assertStageIsolated(input: StageIsolationInput): string[];
56
+ /** The sha256 of the stage env name set plus the resource set: what `bg.<id>.stageisolation` records. */
57
+ export declare function stageIsolationHash(stage: Surface): string;