@wtfalch/foundry 0.42.1 → 0.44.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 (73) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +8 -4
  3. package/dist/index.js +4 -2
  4. package/dist/migrations/0055_release_requests.sql +20 -0
  5. package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
  6. package/dist/migrations/0057_org_customers.sql +29 -0
  7. package/dist/org.d.ts +55 -0
  8. package/dist/org.js +90 -27
  9. package/dist/plan/archon-blue-green.d.ts +54 -0
  10. package/dist/plan/archon-blue-green.js +72 -0
  11. package/dist/plan/blue-green-apply.d.ts +82 -0
  12. package/dist/plan/blue-green-apply.js +360 -0
  13. package/dist/plan/blue-green.d.ts +223 -0
  14. package/dist/plan/blue-green.js +444 -0
  15. package/dist/plan/boule.d.ts +43 -2
  16. package/dist/plan/boule.js +48 -1
  17. package/dist/plan/broker-entries.d.ts +28 -0
  18. package/dist/plan/broker-entries.js +46 -1
  19. package/dist/plan/cloudflared-connector.d.ts +26 -0
  20. package/dist/plan/cloudflared-connector.js +37 -0
  21. package/dist/plan/diff.d.ts +20 -0
  22. package/dist/plan/diff.js +207 -20
  23. package/dist/plan/executor.d.ts +134 -2
  24. package/dist/plan/executor.js +517 -25
  25. package/dist/plan/hostnames.d.ts +7 -0
  26. package/dist/plan/hostnames.js +17 -2
  27. package/dist/plan/locked-hostnames.d.ts +11 -0
  28. package/dist/plan/locked-hostnames.js +13 -0
  29. package/dist/plan/mcp.d.ts +57 -0
  30. package/dist/plan/mcp.js +131 -0
  31. package/dist/plan/org-names.d.ts +2 -2
  32. package/dist/plan/org-names.js +16 -1
  33. package/dist/plan/plan-apply.d.ts +77 -0
  34. package/dist/plan/plan-apply.js +267 -5
  35. package/dist/plan/platform-diff.d.ts +9 -0
  36. package/dist/plan/platform-diff.js +24 -1
  37. package/dist/plan/platform-providers.d.ts +15 -1
  38. package/dist/plan/platform-providers.js +90 -5
  39. package/dist/plan/proxy-labels.d.ts +2 -2
  40. package/dist/plan/proxy-labels.js +14 -4
  41. package/dist/plan/release-host.d.ts +59 -0
  42. package/dist/plan/release-host.js +239 -0
  43. package/dist/plan/remote-database.d.ts +124 -0
  44. package/dist/plan/remote-database.js +373 -0
  45. package/dist/plan/worker-app-database.d.ts +148 -0
  46. package/dist/plan/worker-app-database.js +424 -0
  47. package/dist/plan/worker-app.d.ts +320 -0
  48. package/dist/plan/worker-app.js +792 -0
  49. package/dist/plan/zitadel-machine-user.d.ts +13 -1
  50. package/dist/plan/zitadel-machine-user.js +80 -0
  51. package/dist/platform.d.ts +11 -1
  52. package/dist/platform.js +4 -1
  53. package/dist/release/decide.d.ts +95 -0
  54. package/dist/release/decide.js +242 -0
  55. package/dist/release/health.d.ts +48 -0
  56. package/dist/release/health.js +61 -0
  57. package/dist/release/run.d.ts +40 -0
  58. package/dist/release/run.js +56 -0
  59. package/dist/release/state.d.ts +64 -0
  60. package/dist/release/state.js +126 -0
  61. package/dist/release/types.d.ts +54 -0
  62. package/dist/release/types.js +4 -0
  63. package/dist/requested-changes.d.ts +12 -3
  64. package/dist/requested-changes.js +22 -0
  65. package/dist/store/postgres.d.ts +8 -0
  66. package/dist/store/postgres.js +67 -8
  67. package/dist/store/schema.d.ts +188 -0
  68. package/dist/store/schema.js +15 -0
  69. package/dist/worker-app-config.d.ts +151 -0
  70. package/dist/worker-app-config.js +438 -0
  71. package/package.json +1 -1
  72. package/versions.digests.json +2 -1
  73. package/versions.json +2 -1
@@ -0,0 +1,56 @@
1
+ import { decide, healthRange, } from './decide.js';
2
+ import { formatWeights, formatWindow, recordedFrom, releaseKeys, } from './state.js';
3
+ /**
4
+ * One run for one entry. Reads the recorded keys fresh and the open
5
+ * requests (it consumes none), decides and runs the effects. A failure, or a
6
+ * crash before the caller persists and consumes, leaves the requests open and
7
+ * the recorded keys as they were: the next run decides again from them.
8
+ */
9
+ export async function runRelease(input) {
10
+ const { store, orgId, entryKey, deps, now } = input;
11
+ const ids = await store.getScopeIds(orgId);
12
+ const recorded = recordedFrom(ids, entryKey);
13
+ if (!recorded)
14
+ return { writes: {}, blocked: [], refused: [], decision: null, requestIds: [] };
15
+ const open = await store.openRequests({ orgId, entryKey });
16
+ const actions = open.map((r) => r.action);
17
+ const range = healthRange(recorded, now);
18
+ const target = recorded.release?.to ?? null;
19
+ const health = range ? await deps.readHealth(range) : null;
20
+ const seen = target
21
+ ? await deps.observeColor(target)
22
+ : { status: 'other', address: 'unchecked' };
23
+ const decision = decide({
24
+ recorded,
25
+ actions,
26
+ steps: input.entry.steps,
27
+ stepHoldMinutes: input.entry.stepHoldMinutes,
28
+ now,
29
+ observation: { health, ...seen },
30
+ });
31
+ for (const effect of decision.effects)
32
+ await deps.effect(effect);
33
+ const k = releaseKeys(entryKey);
34
+ const next = decision.recorded;
35
+ const writes = {
36
+ [k.live]: next.live,
37
+ [k.weights]: formatWeights(next.weights),
38
+ [k.digest('blue')]: next.digests.blue,
39
+ [k.digest('green')]: next.digests.green,
40
+ [k.window]: formatWindow(next.window, next.release),
41
+ [k.signal]: decision.signal ? JSON.stringify(decision.signal) : null,
42
+ };
43
+ for (const d of recorded.held)
44
+ if (!next.held.includes(d))
45
+ writes[k.held(d)] = null;
46
+ for (const d of next.held)
47
+ if (!recorded.held.includes(d))
48
+ writes[k.held(d)] = now.toISOString();
49
+ return {
50
+ writes,
51
+ blocked: decision.blocked,
52
+ refused: decision.refused,
53
+ decision,
54
+ requestIds: open.map((r) => r.id),
55
+ };
56
+ }
@@ -0,0 +1,64 @@
1
+ import { z } from 'zod';
2
+ import type { Recorded } from './decide.js';
3
+ import type { Color, ReleaseAction, ReleaseSignal, ReleaseState, WindowState } from './types.js';
4
+ /**
5
+ * The persistence the release runner and Archon share. `getScopeIds` is the
6
+ * recorded `bg.*` keys of an org's applied state (or of the platform, for the
7
+ * orgId `platform`). Requests live in their own table so a click never races
8
+ * the runner's snapshot write.
9
+ */
10
+ export interface ReleaseStore {
11
+ getScopeIds(orgId: string): Promise<Record<string, string>>;
12
+ addRequest(input: {
13
+ orgId: string;
14
+ entryKey: string;
15
+ action: ReleaseAction;
16
+ actor: string;
17
+ }): Promise<void>;
18
+ /** The open requests, oldest first. Reading consumes nothing: a run that fails leaves them open. */
19
+ openRequests(input: {
20
+ orgId: string;
21
+ entryKey: string;
22
+ }): Promise<{
23
+ id: number;
24
+ action: ReleaseAction;
25
+ actor: string;
26
+ }[]>;
27
+ /** Marks requests consumed. The runner calls it only after its decision is persisted. */
28
+ consumeRequests(ids: readonly number[]): Promise<void>;
29
+ }
30
+ /** Scope keys (C3). `entry` is a hosting entry key: never `.` or `:`. */
31
+ export declare function releaseKeys(entry: string): {
32
+ live: string;
33
+ digest: (c: Color) => string;
34
+ weights: string;
35
+ stage: string;
36
+ window: string;
37
+ signal: string;
38
+ heldPrefix: string;
39
+ held: (digest: string) => string;
40
+ };
41
+ export declare function formatWeights(w: Record<Color, number>): string;
42
+ export declare function parseWeights(value: string | undefined): Record<Color, number> | null;
43
+ /** The window JSON is the C2 state plus the active release's `release` field. */
44
+ export declare function parseWindow(value: string | undefined): {
45
+ window: WindowState;
46
+ release: Recorded['release'];
47
+ };
48
+ export declare function formatWindow(window: WindowState, release: Recorded['release']): string;
49
+ export declare function recordedFrom(ids: Record<string, string>, entryKey: string): Recorded | null;
50
+ export declare function readSignal(ids: Record<string, string>, entryKey: string): ReleaseSignal | null;
51
+ /** C2: the release state of one hosting entry, or null before blue/green recorded anything. */
52
+ export declare function readReleaseState(store: ReleaseStore, input: {
53
+ orgId: string;
54
+ entryKey: string;
55
+ }): Promise<ReleaseState | null>;
56
+ declare const actionSchema: z.ZodType<ReleaseAction>;
57
+ /** C2: ask the runner to apply an action on its next run. Archon audits it. */
58
+ export declare function requestReleaseAction(store: ReleaseStore, input: {
59
+ orgId: string;
60
+ entryKey: string;
61
+ action: ReleaseAction;
62
+ actor: string;
63
+ }): Promise<void>;
64
+ export { actionSchema as releaseActionSchema };
@@ -0,0 +1,126 @@
1
+ import { z } from 'zod';
2
+ /** Scope keys (C3). `entry` is a hosting entry key: never `.` or `:`. */
3
+ export function releaseKeys(entry) {
4
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(entry))
5
+ throw new Error('invalid release entry key');
6
+ const p = `bg.${entry}`;
7
+ return {
8
+ live: `${p}.live`,
9
+ digest: (c) => `${p}.digest.${c}`,
10
+ weights: `${p}.weights`,
11
+ stage: `${p}.stage`,
12
+ window: `${p}.window`,
13
+ signal: `${p}.signal`,
14
+ heldPrefix: `${p}.held.`,
15
+ held: (digest) => `${p}.held.${digest}`,
16
+ };
17
+ }
18
+ export function formatWeights(w) {
19
+ return `blue:${w.blue},green:${w.green}`;
20
+ }
21
+ export function parseWeights(value) {
22
+ const m = /^blue:(\d{1,3}),green:(\d{1,3})$/.exec(value ?? '');
23
+ if (!m)
24
+ return null;
25
+ return { blue: Number(m[1]), green: Number(m[2]) };
26
+ }
27
+ const color = z.enum(['blue', 'green']);
28
+ const storedWindow = z.discriminatedUnion('kind', [
29
+ z.object({ kind: z.literal('idle') }),
30
+ z.object({ kind: z.literal('step'), percent: z.number(), since: z.string() }),
31
+ z.object({ kind: z.literal('inconclusive'), percent: z.number(), since: z.string() }),
32
+ z.object({ kind: z.literal('window'), since: z.string() }),
33
+ z.object({ kind: z.literal('passed'), at: z.string() }),
34
+ z.object({ kind: z.literal('rolled_back'), at: z.string(), reason: z.string() }),
35
+ z.object({ kind: z.literal('cap'), since: z.string() }),
36
+ ]);
37
+ const releaseField = z
38
+ .object({
39
+ from: color,
40
+ to: color,
41
+ digest: z.string(),
42
+ fails: z.number().int().nonnegative(),
43
+ checkedAt: z.string().optional(),
44
+ })
45
+ .nullable()
46
+ .optional();
47
+ const signalSchema = z.object({
48
+ requests: z.number(),
49
+ errors: z.number(),
50
+ baselineRate: z.number().nullable(),
51
+ verdict: z.enum(['pass', 'fail', 'hard_fail', 'inconclusive']),
52
+ });
53
+ function parseJson(value) {
54
+ if (!value)
55
+ return undefined;
56
+ try {
57
+ return JSON.parse(value);
58
+ }
59
+ catch {
60
+ // A damaged value reads as absent; the runner rewrites it.
61
+ return undefined;
62
+ }
63
+ }
64
+ /** The window JSON is the C2 state plus the active release's `release` field. */
65
+ export function parseWindow(value) {
66
+ const state = storedWindow.safeParse(parseJson(value));
67
+ if (!state.success)
68
+ return { window: { kind: 'idle' }, release: null };
69
+ const rel = releaseField.safeParse(parseJson(value).release);
70
+ return { window: state.data, release: rel.success ? (rel.data ?? null) : null };
71
+ }
72
+ export function formatWindow(window, release) {
73
+ return JSON.stringify(release ? { ...window, release } : window);
74
+ }
75
+ export function recordedFrom(ids, entryKey) {
76
+ const k = releaseKeys(entryKey);
77
+ const live = color.safeParse(ids[k.live]);
78
+ if (!live.success)
79
+ return null;
80
+ const { window, release } = parseWindow(ids[k.window]);
81
+ return {
82
+ live: live.data,
83
+ digests: { blue: ids[k.digest('blue')] ?? null, green: ids[k.digest('green')] ?? null },
84
+ stageDigest: ids[k.stage] ?? null,
85
+ weights: parseWeights(ids[k.weights]) ?? { ...{ blue: 0, green: 0 }, [live.data]: 100 },
86
+ window,
87
+ release,
88
+ held: Object.keys(ids)
89
+ .filter((key) => key.startsWith(k.heldPrefix))
90
+ .map((key) => key.slice(k.heldPrefix.length))
91
+ .sort(),
92
+ };
93
+ }
94
+ export function readSignal(ids, entryKey) {
95
+ const parsed = signalSchema.safeParse(parseJson(ids[releaseKeys(entryKey).signal]));
96
+ return parsed.success ? parsed.data : null;
97
+ }
98
+ /** C2: the release state of one hosting entry, or null before blue/green recorded anything. */
99
+ export async function readReleaseState(store, input) {
100
+ const ids = await store.getScopeIds(input.orgId);
101
+ const r = recordedFrom(ids, input.entryKey);
102
+ if (!r)
103
+ return null;
104
+ return {
105
+ entryKey: input.entryKey,
106
+ live: r.live,
107
+ weights: r.weights,
108
+ digests: r.digests,
109
+ stageDigest: r.stageDigest,
110
+ window: r.window,
111
+ signal: readSignal(ids, input.entryKey),
112
+ held: r.held,
113
+ };
114
+ }
115
+ const actionSchema = z.discriminatedUnion('kind', [
116
+ z.object({ kind: z.literal('promote') }),
117
+ z.object({ kind: z.literal('rollback') }),
118
+ z.object({ kind: z.literal('clear_held'), digest: z.string().min(1) }),
119
+ z.object({ kind: z.literal('decide_at_cap'), decision: z.enum(['pass', 'rollback']) }),
120
+ ]);
121
+ /** C2: ask the runner to apply an action on its next run. Archon audits it. */
122
+ export async function requestReleaseAction(store, input) {
123
+ releaseKeys(input.entryKey);
124
+ await store.addRequest({ ...input, action: actionSchema.parse(input.action) });
125
+ }
126
+ export { actionSchema as releaseActionSchema };
@@ -0,0 +1,54 @@
1
+ export type Color = 'blue' | 'green';
2
+ export type WindowState = {
3
+ kind: 'idle';
4
+ } | {
5
+ kind: 'step';
6
+ percent: number;
7
+ since: string;
8
+ } | {
9
+ kind: 'inconclusive';
10
+ percent: number;
11
+ since: string;
12
+ } | {
13
+ kind: 'window';
14
+ since: string;
15
+ } | {
16
+ kind: 'passed';
17
+ at: string;
18
+ } | {
19
+ kind: 'rolled_back';
20
+ at: string;
21
+ reason: string;
22
+ } | {
23
+ kind: 'cap';
24
+ since: string;
25
+ };
26
+ export interface ReleaseSignal {
27
+ requests: number;
28
+ errors: number;
29
+ baselineRate: number | null;
30
+ verdict: 'pass' | 'fail' | 'hard_fail' | 'inconclusive';
31
+ }
32
+ export interface ReleaseState {
33
+ entryKey: string;
34
+ live: Color;
35
+ weights: Record<Color, number>;
36
+ digests: Record<Color, string | null>;
37
+ stageDigest: string | null;
38
+ window: WindowState;
39
+ signal: ReleaseSignal | null;
40
+ held: string[];
41
+ }
42
+ export type ReleaseAction = {
43
+ kind: 'promote';
44
+ } | {
45
+ kind: 'rollback';
46
+ } | {
47
+ kind: 'clear_held';
48
+ digest: string;
49
+ } | {
50
+ kind: 'decide_at_cap';
51
+ decision: 'pass' | 'rollback';
52
+ };
53
+ export declare const COLORS: readonly Color[];
54
+ export declare function otherColor(color: Color): Color;
@@ -0,0 +1,4 @@
1
+ export const COLORS = ['blue', 'green'];
2
+ export function otherColor(color) {
3
+ return color === 'blue' ? 'green' : 'blue';
4
+ }
@@ -1,4 +1,4 @@
1
- import type { DeclaredOrg, MailMode, OrgId } from './org.js';
1
+ import { type DeclaredOrg, type MailMode, type OrgId } from './org.js';
2
2
  import type { PlanDetail } from './plan/diff.js';
3
3
  /**
4
4
  * The whitelist of `DeclaredOrg` fields a company's own Boule can request a
@@ -15,13 +15,22 @@ import type { PlanDetail } from './plan/diff.js';
15
15
  * lowering or leaving a limit unchanged -- raising either one needs
16
16
  * approval too, until a billing field exists to say what the raise costs.
17
17
  */
18
- export type RequestableField = 'calls' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier';
18
+ export type RequestableField = 'calls' | 'customers' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier';
19
19
  export declare const REQUESTABLE_FIELDS: readonly RequestableField[];
20
20
  /** Turn the org's own calls Matrix stack (`org.ts`'s `CallsConfig.enabled`) on or off. */
21
21
  export interface CallsChange {
22
22
  field: 'calls';
23
23
  enabled: boolean;
24
24
  }
25
+ /**
26
+ * Turn the org's customers on or off and let them register themselves
27
+ * (`org.ts`'s `CustomersConfig`). `signUp` needs `enabled`: the request is refused otherwise.
28
+ */
29
+ export interface CustomersChange {
30
+ field: 'customers';
31
+ enabled: boolean;
32
+ signUp: boolean;
33
+ }
25
34
  /** Change the org's own spend limit on Files (`org.ts`'s `StorageConfig`). */
26
35
  export interface StorageLimitChange {
27
36
  field: 'storageLimit';
@@ -48,7 +57,7 @@ export interface MailTierChange {
48
57
  * {@link RequestableField}. `classifyChange` and the store both switch on
49
58
  * `.field`.
50
59
  */
51
- export type RequestedChangeValue = CallsChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange;
60
+ export type RequestedChangeValue = CallsChange | CustomersChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange;
52
61
  /**
53
62
  * `auto`: nothing further is required -- archon may carry the request
54
63
  * straight into a plan and apply it. `approval`: a human must approve the
@@ -1,5 +1,7 @@
1
+ import { validateCustomers } from './org.js';
1
2
  export const REQUESTABLE_FIELDS = [
2
3
  'calls',
4
+ 'customers',
3
5
  'storageLimit',
4
6
  'aiLimit',
5
7
  'domain',
@@ -27,6 +29,8 @@ function applyRequestedChange(org, change) {
27
29
  switch (change.field) {
28
30
  case 'calls':
29
31
  return { ...org, calls: { ...org.calls, enabled: change.enabled } };
32
+ case 'customers':
33
+ return { ...org, customers: { enabled: change.enabled, signUp: change.signUp } };
30
34
  case 'storageLimit':
31
35
  return {
32
36
  ...org,
@@ -69,6 +73,15 @@ function isLimitRaise(before, after) {
69
73
  return true;
70
74
  return BigInt(after) > BigInt(before);
71
75
  }
76
+ /** Throws {@link RequestedChangeError} unless both switches are booleans and sign-up comes with customers on. */
77
+ function validateCustomersChange(change) {
78
+ if (typeof change.enabled !== 'boolean' || typeof change.signUp !== 'boolean') {
79
+ throw new RequestedChangeError('customers needs enabled and signUp, both true or false');
80
+ }
81
+ if (change.signUp && !change.enabled) {
82
+ throw new RequestedChangeError('customers.signUp needs customers.enabled');
83
+ }
84
+ }
72
85
  /**
73
86
  * `auto` when `change` is small and safe (`calls`, and `storageLimit`/
74
87
  * `aiLimit` in the lowering or unchanged direction), the plan it causes has
@@ -102,6 +115,14 @@ export function classifyChange(org, change, plan) {
102
115
  if (!REQUESTABLE_FIELDS.includes(change.field)) {
103
116
  throw new RequestedChangeError(`'${change.field}' is not a requestable field`);
104
117
  }
118
+ if (change.field === 'customers') {
119
+ validateCustomersChange(change);
120
+ // The org after the change must still pass the rule apply checks, for example open registration
121
+ // on a hosting entry needs customers on; otherwise the request is stored and fails at apply.
122
+ const invalid = validateCustomers(applyRequestedChange(org, change));
123
+ if (invalid)
124
+ throw new RequestedChangeError(invalid);
125
+ }
105
126
  if (plan?.plannedSteps.some((planned) => planned.step.destroys || planned.step.migrates)) {
106
127
  return 'approval';
107
128
  }
@@ -110,6 +131,7 @@ export function classifyChange(org, change, plan) {
110
131
  return 'approval';
111
132
  switch (change.field) {
112
133
  case 'calls':
134
+ case 'customers':
113
135
  return 'auto';
114
136
  case 'storageLimit':
115
137
  return isLimitRaise(org.storage.limitBytes, change.limitBytes) ||
@@ -1,4 +1,5 @@
1
1
  import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
2
+ import { type ReleaseStore } from '../release/state.js';
2
3
  import { type AppliedVersionsStore, type DeclaredOrgStore, type PlatformStore, type RequestedChangeStore } from '../store.js';
3
4
  /**
4
5
  * The host's own drizzle handle or a transaction open on it, same shape
@@ -98,3 +99,10 @@ export declare function createPostgresRequestedChangeStore(db: FoundryStoreDb):
98
99
  * lost the way `foundry_applied_state`'s one row per service would lose it.
99
100
  */
100
101
  export declare function createPostgresAppliedVersionsStore(db: FoundryStoreDb): AppliedVersionsStore;
102
+ /**
103
+ * `ReleaseStore` (`../release/state.ts`) on Postgres: the recorded `bg.*`
104
+ * keys come from an org's applied state, or the platform's for the orgId
105
+ * `platform`; requests are rows in `foundry_release_requests`. Reading leaves
106
+ * the open rows consumed only when the caller says its run succeeded (`consumeRequests`).
107
+ */
108
+ export declare function createPostgresReleaseStore(db: FoundryStoreDb): ReleaseStore;
@@ -1,12 +1,13 @@
1
- import { and, asc, desc, eq, ne, sql } from 'drizzle-orm';
2
- import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBuyerIdentity, validateCalls, validateDomain, validateDomainClaim, validateHosting, validateMail, validateOwners, validateServices, validateSignIn, validateSite, validateVersionPin, } from '../org.js';
3
- import { lockedHostnames } from '../plan/locked-hostnames.js';
1
+ import { and, asc, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm';
2
+ import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBlueGreen, validateBuyerIdentity, validateCalls, validateCustomers, validateDomain, validateDomainClaim, validateHosting, validateMail, validateOwners, validateServices, validateSignIn, validateSite, validateVersionPin, } from '../org.js';
3
+ import { hostingNames } from '../plan/locked-hostnames.js';
4
4
  import { findNameClashes, nameClashMessage } from '../plan/org-names.js';
5
5
  import { validatePlatform } from '../platform.js';
6
+ import { releaseActionSchema } from '../release/state.js';
6
7
  import { RequestedChangeError, classifyChange } from '../requested-changes.js';
7
8
  import { validateSlug } from '../slug.js';
8
9
  import { StaleLeaseError, } from '../store.js';
9
- import { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryDeclaredPlatform, foundryMutationLeases, foundryRequestedChanges, } from './schema.js';
10
+ import { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryDeclaredPlatform, foundryMutationLeases, foundryReleaseRequests, foundryRequestedChanges, } from './schema.js';
10
11
  const UNIQUE_VIOLATION = '23505';
11
12
  /**
12
13
  * #441: runs `write`, and with a `fence` only if its token is still the
@@ -148,6 +149,10 @@ function rowToOrg(row) {
148
149
  : {}),
149
150
  // Omitted while off.
150
151
  ...(row.signInInApp ? { signIn: { inApp: true } } : {}),
152
+ // Omitted while off.
153
+ ...(row.customersEnabled || row.customersSignUp
154
+ ? { customers: { enabled: row.customersEnabled, signUp: row.customersSignUp } }
155
+ : {}),
151
156
  // Omitted while unset. Validated on read: a stored value is not trusted.
152
157
  ...(row.versionPin ? { versionPin: readVersionPin(row) } : {}),
153
158
  site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
@@ -201,6 +206,8 @@ function orgToRow(org) {
201
206
  callsVersionPinMas: org.calls.versionPin?.mas ?? null,
202
207
  issuerUsesDomain: org.issuer.usesDomain,
203
208
  signInInApp: org.signIn?.inApp ?? false,
209
+ customersEnabled: org.customers?.enabled ?? false,
210
+ customersSignUp: org.customers?.signUp ?? false,
204
211
  servicesOwn: org.services?.own ?? false,
205
212
  servicesSfuSlot: org.services?.sfuSlot ?? null,
206
213
  versionPin: org.versionPin && Object.keys(org.versionPin).length > 0 ? org.versionPin : null,
@@ -350,10 +357,7 @@ export function createPostgresDeclaredOrgStore(db) {
350
357
  // #71: same place `requireValidBuyer` checks the buyer identity (#67), on
351
358
  // both `create` and the merged result of `update`.
352
359
  function requireValidHosting(org) {
353
- const reason = validateHosting(org.hosting, {
354
- domain: org.domain,
355
- locked: lockedHostnames(org, []),
356
- });
360
+ const reason = validateHosting(org.hosting, hostingNames(org, []));
357
361
  if (reason)
358
362
  throw new DeclaredOrgError(`hosting invalid: ${reason} (#71)`);
359
363
  }
@@ -407,6 +411,9 @@ export function createPostgresDeclaredOrgStore(db) {
407
411
  const signInReason = validateSignIn(org);
408
412
  if (signInReason)
409
413
  throw new DeclaredOrgError(`signIn invalid: ${signInReason}`);
414
+ const customersReason = validateCustomers(org);
415
+ if (customersReason)
416
+ throw new DeclaredOrgError(`customers invalid: ${customersReason}`);
410
417
  const slot = org.services?.sfuSlot ?? null;
411
418
  if (slot === null)
412
419
  return;
@@ -718,6 +725,10 @@ function rowToPlatform(row) {
718
725
  enabled: row.archonEnabled,
719
726
  hostname: row.archonHostname,
720
727
  gitRef: row.archonGitRef,
728
+ // Omitted while off. Validated on read: a stored value is not trusted.
729
+ ...(row.archonBlueGreen && validateBlueGreen(row.archonBlueGreen, 'archon') === null
730
+ ? { blueGreen: row.archonBlueGreen }
731
+ : {}),
721
732
  },
722
733
  edge: {
723
734
  enabled: row.edgeEnabled,
@@ -771,6 +782,7 @@ function platformToRow(platform) {
771
782
  archonEnabled: platform.archon.enabled,
772
783
  archonHostname: platform.archon.hostname,
773
784
  archonGitRef: platform.archon.gitRef,
785
+ archonBlueGreen: platform.archon.blueGreen ?? null,
774
786
  edgeEnabled: platform.edge.enabled,
775
787
  edgeHostname: platform.edge.hostname,
776
788
  edgeGitRef: platform.edge.gitRef,
@@ -923,3 +935,50 @@ export function createPostgresAppliedVersionsStore(db) {
923
935
  },
924
936
  };
925
937
  }
938
+ /**
939
+ * `ReleaseStore` (`../release/state.ts`) on Postgres: the recorded `bg.*`
940
+ * keys come from an org's applied state, or the platform's for the orgId
941
+ * `platform`; requests are rows in `foundry_release_requests`. Reading leaves
942
+ * the open rows consumed only when the caller says its run succeeded (`consumeRequests`).
943
+ */
944
+ export function createPostgresReleaseStore(db) {
945
+ return {
946
+ async getScopeIds(orgId) {
947
+ if (orgId === 'platform') {
948
+ const [row] = await db
949
+ .select({ ids: foundryDeclaredPlatform.serviceIds })
950
+ .from(foundryDeclaredPlatform)
951
+ .limit(1);
952
+ return row?.ids ?? {};
953
+ }
954
+ const [row] = await db
955
+ .select({ ids: foundryAppliedState.serviceIds })
956
+ .from(foundryAppliedState)
957
+ .where(eq(foundryAppliedState.orgId, orgId))
958
+ .limit(1);
959
+ return row?.ids ?? {};
960
+ },
961
+ async addRequest({ orgId, entryKey, action, actor }) {
962
+ await db.insert(foundryReleaseRequests).values({ orgId, entryKey, action, actor });
963
+ },
964
+ async openRequests({ orgId, entryKey }) {
965
+ const rows = await db
966
+ .select()
967
+ .from(foundryReleaseRequests)
968
+ .where(and(eq(foundryReleaseRequests.orgId, orgId), eq(foundryReleaseRequests.entryKey, entryKey), isNull(foundryReleaseRequests.consumedAt)))
969
+ .orderBy(asc(foundryReleaseRequests.id));
970
+ return rows.flatMap((row) => {
971
+ const action = releaseActionSchema.safeParse(row.action);
972
+ return action.success ? [{ id: row.id, action: action.data, actor: row.actor }] : [];
973
+ });
974
+ },
975
+ async consumeRequests(ids) {
976
+ if (ids.length === 0)
977
+ return;
978
+ await db
979
+ .update(foundryReleaseRequests)
980
+ .set({ consumedAt: sql `now()` })
981
+ .where(and(inArray(foundryReleaseRequests.id, [...ids]), isNull(foundryReleaseRequests.consumedAt)));
982
+ },
983
+ };
984
+ }