@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
@@ -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,5 +1,5 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
- import { WORKER_APP_HYPERDRIVE_BINDING, WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHostId, } from '../worker-app-config.js';
2
+ import { WORKER_APP_HYPERDRIVE_BINDING, WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHasBuild, workerAppHostId, } from '../worker-app-config.js';
3
3
  import { entryAuthEnv } from './broker-entries.js';
4
4
  import { workerAppHostname } from './hostnames.js';
5
5
  import { appStepKey } from './platform-diff.js';
@@ -315,6 +315,8 @@ export function planWorkerApps(org, recordedIds, input) {
315
315
  description: `${at} is blocked, not planned: ${why}`,
316
316
  key,
317
317
  });
318
+ if (!workerAppHasBuild(app))
319
+ continue;
318
320
  if (!input.available) {
319
321
  block('the host supplies no WorkerAppPort');
320
322
  continue;
@@ -0,0 +1,7 @@
1
+ import { type DeclaredOrg, type OrgRefusal } from './org.js';
2
+ /**
3
+ * The first refusal of the whole-company validation a store write runs for
4
+ * hosting (every entry, its sign-in included), customers and the blue/green of Boule and Agora, or `null`. Locked
5
+ * names are the org's own; the platform's service names are not known here.
6
+ */
7
+ export declare function refuseOrg(org: DeclaredOrg): OrgRefusal | null;
@@ -0,0 +1,12 @@
1
+ import { refuseBuiltinBlueGreen, refuseCustomers, refuseHosting, } from './org.js';
2
+ import { hostingNames } from './plan/locked-hostnames.js';
3
+ /**
4
+ * The first refusal of the whole-company validation a store write runs for
5
+ * hosting (every entry, its sign-in included), customers and the blue/green of Boule and Agora, or `null`. Locked
6
+ * names are the org's own; the platform's service names are not known here.
7
+ */
8
+ export function refuseOrg(org) {
9
+ return (refuseHosting(org.hosting, hostingNames(org, [])) ??
10
+ refuseCustomers(org) ??
11
+ refuseBuiltinBlueGreen(org));
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;
@@ -150,7 +150,7 @@ export function decide(input) {
150
150
  r = rollback(r, now, 'deploy timeout');
151
151
  }
152
152
  else {
153
- const health = observation.health;
153
+ const health = input.hardOnly ? null : observation.health;
154
154
  const newC = health?.colors[rel.to] ?? { requests: 0, errors: 0 };
155
155
  const atWindow = r.window.kind === 'window' || r.window.kind === 'cap';
156
156
  let baseline = null;
@@ -162,6 +162,8 @@ export function decide(input) {
162
162
  let verdict;
163
163
  if (hard || hardRate)
164
164
  verdict = 'hard_fail';
165
+ else if (input.hardOnly)
166
+ verdict = 'pass';
165
167
  else if (!health || baseline === null || newC.requests < RELATIVE_MIN_REQUESTS)
166
168
  verdict = 'inconclusive';
167
169
  else
@@ -200,11 +202,13 @@ export function decide(input) {
200
202
  }
201
203
  else {
202
204
  const required = Math.max(1, Math.ceil(health?.daily.requestsPerDay ?? 0));
203
- const closable = health !== null &&
204
- baseline !== null &&
205
- elapsed >= WINDOW_MIN_HOURS * HOUR &&
206
- newC.requests >= required &&
207
- rate(newC) <= baseline + ERROR_RATE_MARGIN;
205
+ const closable = input.hardOnly
206
+ ? elapsed >= WINDOW_MIN_HOURS * HOUR
207
+ : health !== null &&
208
+ baseline !== null &&
209
+ elapsed >= WINDOW_MIN_HOURS * HOUR &&
210
+ newC.requests >= required &&
211
+ rate(newC) <= baseline + ERROR_RATE_MARGIN;
208
212
  if (closable) {
209
213
  out.effects.push({ kind: 'stop', color: rel.from });
210
214
  r = { ...r, window: { kind: 'passed', at: now.toISOString() }, release: null };
@@ -39,10 +39,15 @@ export declare function readReleaseHealth(input: {
39
39
  headers?: Record<string, string>;
40
40
  fetchImpl?: typeof fetch;
41
41
  }): Promise<HealthRead | null>;
42
- /** An HTTP check of the color's health path on its own address. */
42
+ /**
43
+ * An HTTP check of the color's health path on its own address. A redirect is a failure: it is how a login
44
+ * wall (Cloudflare Access) answers a machine. With `expectJson` the body must also parse as JSON, so a login page served with 200 is not read as a healthy color.
45
+ */
43
46
  export declare function checkColorAddress(input: {
44
47
  address: string;
45
48
  healthPath: string | null;
46
49
  headers?: Record<string, string>;
50
+ /** True for an app whose health path answers JSON; false for a static app's `/`. */
51
+ expectJson?: boolean;
47
52
  fetchImpl?: typeof fetch;
48
53
  }): Promise<'ok' | 'failed'>;
@@ -45,14 +45,22 @@ export async function readReleaseHealth(input) {
45
45
  return null;
46
46
  }
47
47
  }
48
- /** An HTTP check of the color's health path on its own address. */
48
+ /**
49
+ * An HTTP check of the color's health path on its own address. A redirect is a failure: it is how a login
50
+ * wall (Cloudflare Access) answers a machine. With `expectJson` the body must also parse as JSON, so a login page served with 200 is not read as a healthy color.
51
+ */
49
52
  export async function checkColorAddress(input) {
50
53
  try {
51
54
  const res = await (input.fetchImpl ?? fetch)(new URL(input.healthPath ?? '/', input.address), {
52
55
  ...(input.headers ? { headers: input.headers } : {}),
56
+ redirect: 'manual',
53
57
  signal: AbortSignal.timeout(ADDRESS_CHECK_TIMEOUT_MS),
54
58
  });
55
- return res.ok ? 'ok' : 'failed';
59
+ if (!res.ok)
60
+ return 'failed';
61
+ if (input.expectJson)
62
+ await res.json();
63
+ return 'ok';
56
64
  }
57
65
  catch {
58
66
  // Unreachable counts as a failed check.
@@ -37,4 +37,6 @@ export declare function runRelease(input: {
37
37
  };
38
38
  now: Date;
39
39
  deps: ReleaseDeps;
40
+ /** No reporting health read: only hard fails count (see `decide`). */
41
+ hardOnly?: boolean;
40
42
  }): Promise<ReleaseRunResult>;
@@ -16,7 +16,7 @@ export async function runRelease(input) {
16
16
  const actions = open.map((r) => r.action);
17
17
  const range = healthRange(recorded, now);
18
18
  const target = recorded.release?.to ?? null;
19
- const health = range ? await deps.readHealth(range) : null;
19
+ const health = range && !input.hardOnly ? await deps.readHealth(range) : null;
20
20
  const seen = target
21
21
  ? await deps.observeColor(target)
22
22
  : { status: 'other', address: 'unchecked' };
@@ -27,6 +27,7 @@ export async function runRelease(input) {
27
27
  stepHoldMinutes: input.entry.stepHoldMinutes,
28
28
  now,
29
29
  observation: { health, ...seen },
30
+ ...(input.hardOnly ? { hardOnly: true } : {}),
30
31
  });
31
32
  for (const effect of decision.effects)
32
33
  await deps.effect(effect);
@@ -1,4 +1,5 @@
1
- import { type DeclaredOrg, type MailMode, type OrgId } from './org.js';
1
+ import type { BuildJson } from './build-json.js';
2
+ import { type CustomersConfig, type DeclaredOrg, type HostingEntry, type MailMode, type OrgId } from './org.js';
2
3
  import type { PlanDetail } from './plan/diff.js';
3
4
  /**
4
5
  * The whitelist of `DeclaredOrg` fields a company's own Boule can request a
@@ -15,7 +16,7 @@ import type { PlanDetail } from './plan/diff.js';
15
16
  * lowering or leaving a limit unchanged -- raising either one needs
16
17
  * approval too, until a billing field exists to say what the raise costs.
17
18
  */
18
- export type RequestableField = 'calls' | 'customers' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier';
19
+ export type RequestableField = 'calls' | 'customers' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier' | 'app' | 'build';
19
20
  export declare const REQUESTABLE_FIELDS: readonly RequestableField[];
20
21
  /** Turn the org's own calls Matrix stack (`org.ts`'s `CallsConfig.enabled`) on or off. */
21
22
  export interface CallsChange {
@@ -52,12 +53,34 @@ export interface MailTierChange {
52
53
  field: 'mailTier';
53
54
  mode: MailMode;
54
55
  }
56
+ /**
57
+ * Put or remove one hosting entry of the company's own app (`org.ts`'s `HostingEntry`).
58
+ * `before` and `after` are the entry as the requester saw it and as it should be (null when absent).
59
+ * `customers` is the switches after the change when the request changes them, else null.
60
+ * Archon validates the whole company; {@link classifyChange} does not look at the org for this kind.
61
+ */
62
+ export interface AppChange {
63
+ field: 'app';
64
+ key: string;
65
+ op: 'put' | 'remove';
66
+ before: HostingEntry | null;
67
+ after: HostingEntry | null;
68
+ customers: CustomersConfig | null;
69
+ }
70
+ /** Deploy one release of the company's app from its `build.json` (`build-json.ts`). */
71
+ export interface BuildChange {
72
+ field: 'build';
73
+ key: string;
74
+ repo: string;
75
+ release: string;
76
+ build: BuildJson;
77
+ }
55
78
  /**
56
79
  * One company-requested change to its own declared org, keyed by
57
80
  * {@link RequestableField}. `classifyChange` and the store both switch on
58
81
  * `.field`.
59
82
  */
60
- export type RequestedChangeValue = CallsChange | CustomersChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange;
83
+ export type RequestedChangeValue = CallsChange | CustomersChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange | AppChange | BuildChange;
61
84
  /**
62
85
  * `auto`: nothing further is required -- archon may carry the request
63
86
  * straight into a plan and apply it. `approval`: a human must approve the
@@ -72,7 +95,21 @@ export type ChangeClassification = 'auto' | 'approval';
72
95
  * onward. Foundry only stores and classifies (#178 "Why"); nothing in this
73
96
  * package transitions a state past its initial value.
74
97
  */
75
- export type RequestedChangeState = 'auto' | 'needs_approval' | 'approved' | 'rejected' | 'applied';
98
+ export type RequestedChangeState = 'auto' | 'needs_approval' | 'approved' | 'rejected' | 'applied' | 'applying' | 'failed';
99
+ /** Why an `app` or `build` request is `failed`. */
100
+ export type FailureCode = 'replaced' | 'refused' | 'busy' | 'timed_out' | 'apply_error';
101
+ /** Who asked: a person in Boule, or a release build. `requestedBy` keeps the raw principal id for audit. */
102
+ export type RequestedBy = {
103
+ via: 'boule';
104
+ person: {
105
+ id: string;
106
+ name: string;
107
+ };
108
+ } | {
109
+ via: 'build';
110
+ repo: string;
111
+ release: string;
112
+ };
76
113
  /**
77
114
  * One company-requested change to its own declared org, as stored --
78
115
  * `migrations/0017_requested_changes.sql`'s `foundry_requested_changes`
@@ -85,6 +122,15 @@ export interface RequestedChange {
85
122
  requestedBy: string;
86
123
  createdAt: string;
87
124
  state: RequestedChangeState;
125
+ /** Null for rows made before `app` and `build` requests. */
126
+ requester: RequestedBy | null;
127
+ idempotencyKey: string | null;
128
+ reason: {
129
+ code: FailureCode;
130
+ message: string;
131
+ replacedBy?: number;
132
+ } | null;
133
+ updatedAt: string;
88
134
  }
89
135
  /**
90
136
  * Thrown by {@link classifyChange} (and by the requested-change store's
@@ -1,4 +1,4 @@
1
- import { validateCustomers } from './org.js';
1
+ import { validateCustomers, } from './org.js';
2
2
  export const REQUESTABLE_FIELDS = [
3
3
  'calls',
4
4
  'customers',
@@ -6,6 +6,8 @@ export const REQUESTABLE_FIELDS = [
6
6
  'aiLimit',
7
7
  'domain',
8
8
  'mailTier',
9
+ 'app',
10
+ 'build',
9
11
  ];
10
12
  /**
11
13
  * Thrown by {@link classifyChange} (and by the requested-change store's
@@ -42,6 +44,9 @@ function applyRequestedChange(org, change) {
42
44
  return { ...org, domain: change.domain };
43
45
  case 'mailTier':
44
46
  return { ...org, mail: { ...org.mail, mode: change.mode } };
47
+ case 'app':
48
+ case 'build':
49
+ return org;
45
50
  }
46
51
  }
47
52
  /**
@@ -115,6 +120,9 @@ export function classifyChange(org, change, plan) {
115
120
  if (!REQUESTABLE_FIELDS.includes(change.field)) {
116
121
  throw new RequestedChangeError(`'${change.field}' is not a requestable field`);
117
122
  }
123
+ // Archon validates the whole company for these; nothing waits for approval.
124
+ if (change.field === 'app' || change.field === 'build')
125
+ return 'auto';
118
126
  if (change.field === 'customers') {
119
127
  validateCustomersChange(change);
120
128
  // The org after the change must still pass the rule apply checks, for example open registration
@@ -1,4 +1,5 @@
1
1
  import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
2
+ import type { OrgRefusal, OrgRefusalCode } from '../org.js';
2
3
  import { type ReleaseStore } from '../release/state.js';
3
4
  import { type AppliedVersionsStore, type DeclaredOrgStore, type PlatformStore, type RequestedChangeStore } from '../store.js';
4
5
  /**
@@ -38,7 +39,10 @@ export declare function uniqueViolationConstraint(error: unknown): string | unde
38
39
  * is that throw.
39
40
  */
40
41
  export declare class DeclaredOrgError extends Error {
41
- constructor(message: string);
42
+ /** Set when a hosting or customers validator refused the write: which rule, and where in the org. */
43
+ readonly code?: OrgRefusalCode;
44
+ readonly path?: string;
45
+ constructor(message: string, refusal?: Pick<OrgRefusal, 'code' | 'path'>);
42
46
  }
43
47
  /**
44
48
  * `DeclaredOrgStore` (`../store.ts`) on Postgres, for #18: create, read and