@wtfalch/foundry 0.47.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 (44) hide show
  1. package/dist/index.d.ts +3 -1
  2. package/dist/index.js +1 -0
  3. package/dist/migrations/0059_org_builtin_blue_green.sql +9 -0
  4. package/dist/org.d.ts +17 -1
  5. package/dist/org.js +24 -3
  6. package/dist/plan/archon-blue-green.js +15 -1
  7. package/dist/plan/blue-green-apply.d.ts +35 -0
  8. package/dist/plan/blue-green-apply.js +167 -16
  9. package/dist/plan/blue-green.d.ts +97 -2
  10. package/dist/plan/blue-green.js +214 -13
  11. package/dist/plan/database-dump.d.ts +37 -0
  12. package/dist/plan/database-dump.js +101 -0
  13. package/dist/plan/diff.js +3 -0
  14. package/dist/plan/executor.d.ts +48 -0
  15. package/dist/plan/executor.js +79 -20
  16. package/dist/plan/index.d.ts +2 -0
  17. package/dist/plan/index.js +4 -0
  18. package/dist/plan/locked-hostnames.d.ts +1 -0
  19. package/dist/plan/locked-hostnames.js +1 -0
  20. package/dist/plan/plan-apply.js +16 -7
  21. package/dist/plan/platform-diff.js +2 -1
  22. package/dist/plan/platform-providers.js +2 -0
  23. package/dist/plan/proxy-labels.d.ts +13 -1
  24. package/dist/plan/proxy-labels.js +26 -3
  25. package/dist/plan/release-host.d.ts +1 -1
  26. package/dist/plan/release-host.js +36 -14
  27. package/dist/plan/stage-env.d.ts +38 -0
  28. package/dist/plan/stage-env.js +52 -0
  29. package/dist/plan/stage-guards.d.ts +104 -0
  30. package/dist/plan/stage-guards.js +149 -0
  31. package/dist/plan/stage-isolation.d.ts +57 -0
  32. package/dist/plan/stage-isolation.js +248 -0
  33. package/dist/refuse-org.d.ts +1 -1
  34. package/dist/refuse-org.js +5 -3
  35. package/dist/release/decide.d.ts +5 -0
  36. package/dist/release/decide.js +10 -6
  37. package/dist/release/health.d.ts +6 -1
  38. package/dist/release/health.js +10 -2
  39. package/dist/release/run.d.ts +2 -0
  40. package/dist/release/run.js +2 -1
  41. package/dist/store/postgres.js +20 -1
  42. package/dist/store/schema.d.ts +39 -1
  43. package/dist/store/schema.js +3 -0
  44. package/package.json +1 -1
@@ -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;
@@ -0,0 +1,248 @@
1
+ import { createHash } from 'node:crypto';
2
+ const SECRET_NAME = /(KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|KEK|DATABASE_URL)/;
3
+ const URL_NAME = /(_URL|_URI|_UPSTREAM|_ISSUER)$/;
4
+ const ID_NAME = /(_ID|_ORGANI[SZ]ATION_ID)$/;
5
+ /** The class of an env name, from its spelling. */
6
+ export function envClass(name) {
7
+ if (SECRET_NAME.test(name))
8
+ return 'secret';
9
+ if (URL_NAME.test(name))
10
+ return 'url';
11
+ if (ID_NAME.test(name))
12
+ return 'id';
13
+ return 'literal';
14
+ }
15
+ /** `AB_*` matches by prefix; anything else matches exactly. */
16
+ function globMatches(pattern, name) {
17
+ return pattern.endsWith('*') ? name.startsWith(pattern.slice(0, -1)) : pattern === name;
18
+ }
19
+ const matchesAny = (patterns, name) => patterns.some((p) => globMatches(p, name));
20
+ const MAIL_NAME = /^(MAIL_|LETTERMINT_|SMTP_)/;
21
+ /** Env names (or `PREFIX_*`) each kind of stage app may hold. A name not here is refused. */
22
+ export const STAGE_ALLOWLIST = {
23
+ boule: [
24
+ 'APP_URL',
25
+ 'BOULE_ORG_NAME',
26
+ 'AUTH_ISSUER',
27
+ 'AUTH_ORGANIZATION_ID',
28
+ 'AUTH_CLIENT_ID',
29
+ 'AUTH_APP_KEY',
30
+ 'AUTH_COOKIE_SECRET',
31
+ 'DATABASE_URL',
32
+ 'DATABASE_URL_OWNER',
33
+ 'LOG_DIR',
34
+ 'NEXT_SERVER_ACTIONS_ENCRYPTION_KEY',
35
+ 'REPORTING_*',
36
+ 'AUTHZ_BOOTSTRAP_OPERATOR*',
37
+ 'AUTHZ_FIRST_OWNER*',
38
+ 'CUSTOMERS_ORGANISATION_ID',
39
+ 'ARCHON_URL',
40
+ 'ARCHON_ORG_ID',
41
+ 'ARCHON_CREDENTIAL',
42
+ 'ARCHON_BRIDGE_SECRET',
43
+ 'AUTHZ_PEER_PASSWORD_OPERATOR',
44
+ 'OWNER_SEED_SECRET',
45
+ ],
46
+ agora: ['AGORA_CONFIG', 'SIGNIN_UPSTREAM', 'LOG_DIR'],
47
+ archon: [
48
+ 'APP_URL',
49
+ 'AUTH_ISSUER',
50
+ 'AUTH_ORGANIZATION_ID',
51
+ 'CI_ORGANIZATION_ID',
52
+ 'AUTH_CLIENT_ID',
53
+ 'AUTH_APP_KEY',
54
+ 'AUTH_COOKIE_SECRET',
55
+ 'DATABASE_URL',
56
+ 'DATABASE_URL_OWNER',
57
+ 'AUTHZ_DATABASE_URL',
58
+ 'BOULE_URL',
59
+ 'ARCHON_BRIDGE_SECRET',
60
+ 'REPORTING_SITE',
61
+ 'KEYSTORE_KEK',
62
+ 'LOG_LEVEL',
63
+ 'LOG_DIR',
64
+ 'NEXT_SERVER_ACTIONS_ENCRYPTION_KEY',
65
+ ],
66
+ customer: [
67
+ 'LOG_DIR',
68
+ 'NEXT_SERVER_ACTIONS_ENCRYPTION_KEY',
69
+ 'APP_URL',
70
+ 'APP_ENVIRONMENT',
71
+ 'DATABASE_URL',
72
+ 'DATABASE_URL_OWNER',
73
+ 'AUTH_ISSUER',
74
+ 'AUTH_ORGANIZATION_ID',
75
+ 'AUTH_CLIENT_ID',
76
+ 'AUTH_APP_KEY',
77
+ 'AUTH_COOKIE_SECRET',
78
+ 'FILES_URL',
79
+ 'FILES_ORGANISATION_ID',
80
+ 'FILES_API_KEY',
81
+ 'AI_URL',
82
+ 'AI_ORGANISATION_ID',
83
+ 'AI_API_KEY',
84
+ 'AI_WEBHOOK_SECRET',
85
+ 'JOBS_SCHEDULER_URL',
86
+ 'JOBS_SIGNING_SECRET',
87
+ 'JOBS_SYNC_KEY',
88
+ 'ANALYTICS_URL',
89
+ 'ANALYTICS_SITE_KEY',
90
+ ],
91
+ };
92
+ /** Names no stage holds, whatever the app. */
93
+ const DENY_COMMON = [
94
+ 'LETTERMINT_API_TOKEN',
95
+ 'MAIL_ADMIN_*',
96
+ 'MAIL_SMTP_*',
97
+ 'OFFBOARDING_SERVICES',
98
+ 'CHAT_ADMIN_INSTANCES',
99
+ 'COOLIFY_*',
100
+ 'CF_*',
101
+ 'CLOUDFLARE_*',
102
+ 'ZITADEL_*',
103
+ 'TUNNEL_TOKEN',
104
+ 'CUSTOMERS_DIRECTORY_CREDENTIAL',
105
+ 'FOUNDRY_*',
106
+ 'INTEGRATIONS_*',
107
+ 'KEYSTORE_KEK_PREVIOUS',
108
+ 'UPTIME_*',
109
+ 'JOBS_ADMIN_TOKEN',
110
+ 'CUSTOMER_GRANTS',
111
+ 'DEPLOYMENT_CAPABILITIES',
112
+ 'CHARGE_TOKEN',
113
+ 'USAGE_TOKEN',
114
+ 'RELEASE_TICK_TOKEN',
115
+ 'ARCHON_SETUP_CODE',
116
+ 'ARCHON_FIRST_OWNER_LINK',
117
+ 'MAINTENANCE_BYPASS_TOKEN',
118
+ ];
119
+ const DENY_BY_KIND = {
120
+ boule: ['JOBS_*', 'EDGE_*'],
121
+ agora: [
122
+ 'AGORA_*_UPSTREAM',
123
+ 'AGORA_MAIL_UPSTREAM',
124
+ 'AGORA_FILES_UPSTREAM',
125
+ 'AGORA_CHAT_UPSTREAM',
126
+ 'AGORA_MATRIX_UPSTREAM',
127
+ 'AGORA_MAS_UPSTREAM',
128
+ 'AGORA_CALLS_UPSTREAM',
129
+ 'MESSAGING_*',
130
+ 'MAIL_PROJECT_ID',
131
+ 'FILES_PROJECT_ID',
132
+ 'AGORA_UPDATE_FEED_URL',
133
+ ],
134
+ archon: ['FILES_*', 'AI_*', 'JOBS_*', 'KEYS_*', 'EDGE_*'],
135
+ customer: ['EDGE_*', 'KEYS_*'],
136
+ };
137
+ /** Whether `name` is on the deny list of `kind`. */
138
+ export function isDenied(kind, name) {
139
+ return matchesAny(DENY_COMMON, name) || matchesAny(DENY_BY_KIND[kind], name);
140
+ }
141
+ /** Whether `name` is on the allowlist of `kind`. Mail names are checked by A5 only. */
142
+ export function isAllowed(kind, name) {
143
+ return matchesAny(STAGE_ALLOWLIST[kind], name);
144
+ }
145
+ /**
146
+ * Env that stage holds with the same value as production, on purpose. Each entry says why that cannot write
147
+ * to production. A name not here must differ from every production value.
148
+ */
149
+ export const SHARED_WITH_PROD = {
150
+ AUTH_ISSUER: 'stage signs in through the same issuer by decision; it holds no ZITADEL management token, and an OIDC client cannot write users',
151
+ AUTH_ORGANIZATION_ID: 'same issuer organisation as AUTH_ISSUER; an id, not a credential',
152
+ CI_ORGANIZATION_ID: 'an id of the same issuer organisation; not a credential',
153
+ CUSTOMERS_ORGANISATION_ID: 'an id of the customers organisation in the same issuer; not a credential',
154
+ BOULE_ORG_NAME: 'a display name',
155
+ ARCHON_ORG_ID: 'the exact copy of the database holds the same organisation rows',
156
+ 'AUTHZ_BOOTSTRAP_OPERATOR*': 'who may operate stage; an address, not a credential',
157
+ 'AUTHZ_FIRST_OWNER*': 'who owns stage; an address, not a credential',
158
+ FILES_URL: 'the files service is shared; stage reaches it only with its own organisation and key',
159
+ AI_URL: 'the ai service is shared; stage reaches it only with its own organisation and key',
160
+ JOBS_SCHEDULER_URL: 'the jobs service is shared; stage reaches it only with its own project and signing secret',
161
+ ANALYTICS_URL: 'the analytics collector is shared; stage reports under its own site key',
162
+ };
163
+ /** Whether `name` may equal production's value. */
164
+ export const isSharedWithProd = (name) => Object.keys(SHARED_WITH_PROD).some((p) => globMatches(p, name));
165
+ const LIVE_MODE_KEY = /(^|[^a-z0-9])(sk|rk)_live_|^live_/;
166
+ const sha256 = (value) => createHash('sha256').update(value).digest('hex');
167
+ const URL_HOST = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/?#]*@)?([^/?#:]+)/i;
168
+ const hostOf = (value) => URL_HOST.exec(value)?.[1]?.toLowerCase() ?? null;
169
+ /**
170
+ * The violations of stage isolation, as `<code>:<name>`; empty when stage is isolated. Names only: an env
171
+ * name or a resource kind, never a value.
172
+ * - A1 allowlist: `env_not_allowed`. A2 deny list: `env_denied`.
173
+ * - A3 values: `value_shared` (a secret, url or id equals a production value), `shared_differs` (a name on
174
+ * {@link SHARED_WITH_PROD} differs from production's), `live_mode_key`.
175
+ * - A4 names: `resource_shared`, `host_is_prod_internal`.
176
+ * - A5 mail: `mail_not_sink`, `mail_token`.
177
+ */
178
+ export function assertStageIsolated(input) {
179
+ const { kind, prod, stage } = input;
180
+ const sink = input.sinkMail ?? {};
181
+ const out = new Set();
182
+ const prodByName = new Map();
183
+ for (const item of prod.env)
184
+ prodByName.set(item.name, item);
185
+ const prodHashes = new Set();
186
+ const prodRefs = new Set();
187
+ const prodHosts = new Set((prod.internalHosts ?? []).map((h) => h.toLowerCase()));
188
+ for (const item of prod.env) {
189
+ if (item.class === 'literal')
190
+ continue;
191
+ if (item.value) {
192
+ prodHashes.add(sha256(item.value));
193
+ const host = hostOf(item.value);
194
+ if (host)
195
+ prodHosts.add(host);
196
+ }
197
+ if (item.ref)
198
+ prodRefs.add(item.ref);
199
+ }
200
+ for (const item of stage.env) {
201
+ const { name } = item;
202
+ const isMail = MAIL_NAME.test(name);
203
+ if (isMail) {
204
+ if (item.value !== sink[name]) {
205
+ out.add(`${name === 'LETTERMINT_API_TOKEN' ? 'mail_token' : 'mail_not_sink'}:${name}`);
206
+ }
207
+ }
208
+ if (isDenied(kind, name))
209
+ out.add(`env_denied:${name}`);
210
+ else if (!isMail && !isAllowed(kind, name))
211
+ out.add(`env_not_allowed:${name}`);
212
+ if (item.value !== undefined && LIVE_MODE_KEY.test(item.value))
213
+ out.add(`live_mode_key:${name}`);
214
+ if (item.class === 'literal')
215
+ continue;
216
+ const shared = isSharedWithProd(name);
217
+ const counterpart = prodByName.get(name);
218
+ if (shared) {
219
+ const same = item.value !== undefined && counterpart?.value !== undefined
220
+ ? item.value === counterpart.value
221
+ : item.ref === counterpart?.ref;
222
+ if (counterpart && !same)
223
+ out.add(`shared_differs:${name}`);
224
+ continue;
225
+ }
226
+ if (item.value && prodHashes.has(sha256(item.value)))
227
+ out.add(`value_shared:${name}`);
228
+ if (item.ref && prodRefs.has(item.ref))
229
+ out.add(`value_shared:${name}`);
230
+ const host = item.value ? hostOf(item.value) : null;
231
+ if (host && prodHosts.has(host))
232
+ out.add(`host_is_prod_internal:${name}`);
233
+ }
234
+ const prodResources = new Set(Object.values(prod.resources));
235
+ for (const [resourceKind, value] of Object.entries(stage.resources)) {
236
+ if (prodResources.has(value))
237
+ out.add(`resource_shared:${resourceKind}`);
238
+ }
239
+ return [...out].sort();
240
+ }
241
+ /** The sha256 of the stage env name set plus the resource set: what `bg.<id>.stageisolation` records. */
242
+ export function stageIsolationHash(stage) {
243
+ const names = stage.env.map((e) => e.name).sort();
244
+ const resources = Object.entries(stage.resources)
245
+ .map(([k, v]) => `${k}=${v}`)
246
+ .sort();
247
+ return sha256(JSON.stringify({ names, resources }));
248
+ }
@@ -1,7 +1,7 @@
1
1
  import { type DeclaredOrg, type OrgRefusal } from './org.js';
2
2
  /**
3
3
  * The first refusal of the whole-company validation a store write runs for
4
- * hosting (every entry, its sign-in included) and customers, or `null`. Locked
4
+ * hosting (every entry, its sign-in included), customers and the blue/green of Boule and Agora, or `null`. Locked
5
5
  * names are the org's own; the platform's service names are not known here.
6
6
  */
7
7
  export declare function refuseOrg(org: DeclaredOrg): OrgRefusal | null;
@@ -1,10 +1,12 @@
1
- import { refuseCustomers, refuseHosting } from './org.js';
1
+ import { refuseBuiltinBlueGreen, refuseCustomers, refuseHosting, } from './org.js';
2
2
  import { hostingNames } from './plan/locked-hostnames.js';
3
3
  /**
4
4
  * The first refusal of the whole-company validation a store write runs for
5
- * hosting (every entry, its sign-in included) and customers, or `null`. Locked
5
+ * hosting (every entry, its sign-in included), customers and the blue/green of Boule and Agora, or `null`. Locked
6
6
  * names are the org's own; the platform's service names are not known here.
7
7
  */
8
8
  export function refuseOrg(org) {
9
- return refuseHosting(org.hosting, hostingNames(org, [])) ?? refuseCustomers(org);
9
+ return (refuseHosting(org.hosting, hostingNames(org, [])) ??
10
+ refuseCustomers(org) ??
11
+ refuseBuiltinBlueGreen(org));
10
12
  }
@@ -92,4 +92,9 @@ export declare function decide(input: {
92
92
  stepHoldMinutes?: number | undefined;
93
93
  now: Date;
94
94
  observation: Observation;
95
+ /**
96
+ * The app has no reporting health read (a static app). Only hard fails count: it never reports a relative
97
+ * fail, steps up by time, and the window closes after its minimum time.
98
+ */
99
+ hardOnly?: boolean;
95
100
  }): Decision;