@wtfalch/foundry 0.45.1 → 0.47.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 (78) 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/org.d.ts +68 -5
  7. package/dist/org.js +287 -135
  8. package/dist/plan/agora.d.ts +14 -0
  9. package/dist/plan/agora.js +21 -0
  10. package/dist/plan/backup-gate.d.ts +15 -0
  11. package/dist/plan/backup-gate.js +38 -0
  12. package/dist/plan/boule-owners-http.d.ts +12 -0
  13. package/dist/plan/boule-owners-http.js +59 -0
  14. package/dist/plan/boule.d.ts +38 -2
  15. package/dist/plan/boule.js +72 -1
  16. package/dist/plan/company-backups.d.ts +7 -1
  17. package/dist/plan/company-backups.js +42 -2
  18. package/dist/plan/cookie-domain.d.ts +41 -0
  19. package/dist/plan/cookie-domain.js +71 -0
  20. package/dist/plan/desktop-auth.d.ts +58 -0
  21. package/dist/plan/desktop-auth.js +144 -0
  22. package/dist/plan/desktop-callback.d.ts +26 -0
  23. package/dist/plan/desktop-callback.js +59 -0
  24. package/dist/plan/diff.d.ts +13 -4
  25. package/dist/plan/diff.js +277 -88
  26. package/dist/plan/executor.d.ts +136 -6
  27. package/dist/plan/executor.js +732 -105
  28. package/dist/plan/health-gaps.js +5 -0
  29. package/dist/plan/health-targets.js +1 -2
  30. package/dist/plan/hostnames.d.ts +2 -2
  31. package/dist/plan/hostnames.js +6 -6
  32. package/dist/plan/index.d.ts +4 -1
  33. package/dist/plan/index.js +3 -1
  34. package/dist/plan/job-app.d.ts +23 -0
  35. package/dist/plan/job-app.js +41 -0
  36. package/dist/plan/live.d.ts +6 -0
  37. package/dist/plan/live.js +16 -0
  38. package/dist/plan/login-host.d.ts +28 -0
  39. package/dist/plan/login-host.js +36 -0
  40. package/dist/plan/org-names.js +9 -1
  41. package/dist/plan/org-postgres-job.d.ts +22 -0
  42. package/dist/plan/org-postgres-job.js +91 -0
  43. package/dist/plan/org-postgres-migrate.d.ts +115 -0
  44. package/dist/plan/org-postgres-migrate.js +466 -0
  45. package/dist/plan/org-postgres.d.ts +90 -0
  46. package/dist/plan/org-postgres.js +293 -0
  47. package/dist/plan/plan-apply.d.ts +60 -3
  48. package/dist/plan/plan-apply.js +174 -8
  49. package/dist/plan/platform-diff.d.ts +45 -0
  50. package/dist/plan/platform-diff.js +159 -4
  51. package/dist/plan/platform-org.d.ts +2 -0
  52. package/dist/plan/platform-org.js +4 -0
  53. package/dist/plan/platform-providers.d.ts +12 -2
  54. package/dist/plan/platform-providers.js +282 -9
  55. package/dist/plan/post-logout-uris.d.ts +22 -0
  56. package/dist/plan/post-logout-uris.js +43 -0
  57. package/dist/plan/providers-readers.d.ts +10 -1
  58. package/dist/plan/providers-readers.js +56 -5
  59. package/dist/plan/sign-in-bindings.d.ts +4 -0
  60. package/dist/plan/sign-in-bindings.js +10 -0
  61. package/dist/plan/sign-in-org.d.ts +19 -6
  62. package/dist/plan/sign-in-org.js +35 -10
  63. package/dist/plan/sign-in.d.ts +12 -5
  64. package/dist/plan/sign-in.js +12 -5
  65. package/dist/plan/signin-db-move.js +10 -34
  66. package/dist/plan/worker-app.js +3 -1
  67. package/dist/refuse-org.d.ts +7 -0
  68. package/dist/refuse-org.js +10 -0
  69. package/dist/requested-changes.d.ts +50 -4
  70. package/dist/requested-changes.js +9 -1
  71. package/dist/store/postgres.d.ts +5 -1
  72. package/dist/store/postgres.js +67 -8
  73. package/dist/store/schema.d.ts +72 -0
  74. package/dist/store/schema.js +4 -0
  75. package/dist/store.d.ts +24 -1
  76. package/dist/worker-app-config.d.ts +8 -2
  77. package/dist/worker-app-config.js +20 -6
  78. package/package.json +5 -5
@@ -12,6 +12,20 @@ import type { DeclaredOrg, OrgId } from '../org.js';
12
12
  * (recorded by `executor.ts`'s `zitadelApp`).
13
13
  */
14
14
  export declare const ZITADEL_ORG_SCOPE_KEY = "zitadel_org";
15
+ /**
16
+ * #440: Agora's desktop sign-in backend, a second app that holds the
17
+ * messaging desktop app's handoff state. The app's id is recorded as soon as
18
+ * it exists; the deployed marker only once it ran healthy, and it is what
19
+ * tells `desiredAgoraEnv` to write the upstream, so Agora never proxies to an
20
+ * address that does not answer.
21
+ */
22
+ export declare const DESKTOP_AUTH_APP_SCOPE_KEY = "coolify_desktop_auth_app";
23
+ export declare const DESKTOP_AUTH_DEPLOYED_SCOPE_KEY = "coolify_desktop_auth_deployed";
24
+ export declare const DESKTOP_AUTH_PORT = "8080";
25
+ /** The desktop-auth app's fixed internal name; Agora reaches it as `<name>:8080` on the company network. */
26
+ export declare function desktopAuthInternalName(org: Pick<DeclaredOrg, 'slug'>): string;
27
+ /** The value of Agora's `AGORA_DESKTOP_AUTH_UPSTREAM`. */
28
+ export declare function desktopAuthUpstream(org: Pick<DeclaredOrg, 'slug'>): string;
15
29
  /**
16
30
  * The org's ZITADEL messaging project: the audience chat-service requires in
17
31
  * a token's `aud`, and Agora's `messagingProjectId`. `executor.ts`'s
@@ -14,6 +14,24 @@ import { SIGN_IN_UPSTREAM_ENV, inAppHostApplied, signInUpstream, staleSignInUpst
14
14
  * (recorded by `executor.ts`'s `zitadelApp`).
15
15
  */
16
16
  export const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
17
+ /**
18
+ * #440: Agora's desktop sign-in backend, a second app that holds the
19
+ * messaging desktop app's handoff state. The app's id is recorded as soon as
20
+ * it exists; the deployed marker only once it ran healthy, and it is what
21
+ * tells `desiredAgoraEnv` to write the upstream, so Agora never proxies to an
22
+ * address that does not answer.
23
+ */
24
+ export const DESKTOP_AUTH_APP_SCOPE_KEY = 'coolify_desktop_auth_app';
25
+ export const DESKTOP_AUTH_DEPLOYED_SCOPE_KEY = 'coolify_desktop_auth_deployed';
26
+ export const DESKTOP_AUTH_PORT = '8080';
27
+ /** The desktop-auth app's fixed internal name; Agora reaches it as `<name>:8080` on the company network. */
28
+ export function desktopAuthInternalName(org) {
29
+ return `${org.slug}-desktop-auth`;
30
+ }
31
+ /** The value of Agora's `AGORA_DESKTOP_AUTH_UPSTREAM`. */
32
+ export function desktopAuthUpstream(org) {
33
+ return `${desktopAuthInternalName(org)}:${DESKTOP_AUTH_PORT}`;
34
+ }
17
35
  /**
18
36
  * The org's ZITADEL messaging project: the audience chat-service requires in
19
37
  * a token's `aud`, and Agora's `messagingProjectId`. `executor.ts`'s
@@ -294,6 +312,9 @@ export function desiredAgoraEnv(org, recorded, rawHost) {
294
312
  ...(inAppHostApplied(org, recorded, agoraHostnameFor(org)) ? { signInInApp: true } : {}),
295
313
  });
296
314
  }
315
+ if (recorded[DESKTOP_AUTH_DEPLOYED_SCOPE_KEY]) {
316
+ env.AGORA_DESKTOP_AUTH_UPSTREAM = desktopAuthUpstream(org);
317
+ }
297
318
  if (org.mail.mode !== 'off' && host.mailUpstream) {
298
319
  env.AGORA_MAIL_UPSTREAM = host.mailUpstream;
299
320
  }
@@ -37,6 +37,15 @@ export declare class SigninDbMoveBackupMissingError extends Error {
37
37
  readonly reason: 'no-gate' | 'no-backup';
38
38
  constructor(reason: 'no-gate' | 'no-backup');
39
39
  }
40
+ /**
41
+ * The migration of a live company's database to its Postgres server (#444) is refused before any step
42
+ * runs when the old database has no finished backup from the last {@link BACKUP_MAX_AGE_HOURS} hours, or
43
+ * the host has no backup gate. The message names no database, uuid or URL. There is no override.
44
+ */
45
+ export declare class OrgPostgresMigrationBackupMissingError extends Error {
46
+ readonly reason: 'no-gate' | 'no-backup';
47
+ constructor(reason: 'no-gate' | 'no-backup');
48
+ }
40
49
  export interface BackupGatePorts {
41
50
  /** The company's recorded ids (`AppliedState.serviceIds`): database uuids and `backup_<db>` schedule uuids. */
42
51
  recordedIds: Readonly<Record<string, string>>;
@@ -83,6 +92,12 @@ export declare function requireFreshBackupsForUpgrades(plan: {
83
92
  * uuid of the execution that passed.
84
93
  */
85
94
  export declare function requireFreshBackupForSigninMove(ports: BackupGatePorts): Promise<string>;
95
+ /**
96
+ * The org Postgres migration's gate (#444): the old database of `home` needs a schedule and a `success`
97
+ * execution not older than {@link BACKUP_MAX_AGE_HOURS} hours, read from Coolify's executions. A schedule
98
+ * alone proves no dump ran. Returns the uuid of the execution that passed.
99
+ */
100
+ export declare function requireFreshBackupForOrgPostgresMigration(ports: BackupGatePorts, legacyDatabaseKey: string, backupName: string): Promise<string>;
86
101
  export interface MoveGatePorts {
87
102
  /** The company's Coolify project (`coolify_project`). */
88
103
  projectUuid: string | undefined;
@@ -59,6 +59,21 @@ export class SigninDbMoveBackupMissingError extends Error {
59
59
  this.name = 'SigninDbMoveBackupMissingError';
60
60
  }
61
61
  }
62
+ /**
63
+ * The migration of a live company's database to its Postgres server (#444) is refused before any step
64
+ * runs when the old database has no finished backup from the last {@link BACKUP_MAX_AGE_HOURS} hours, or
65
+ * the host has no backup gate. The message names no database, uuid or URL. There is no override.
66
+ */
67
+ export class OrgPostgresMigrationBackupMissingError extends Error {
68
+ reason;
69
+ constructor(reason) {
70
+ super(reason === 'no-gate'
71
+ ? 'org postgres migration refused: this host has no backup gate, so a fresh backup cannot be checked'
72
+ : `org postgres migration refused: the old database has no finished backup from the last ${BACKUP_MAX_AGE_HOURS} hours, checked in Coolify; take one now and apply again`);
73
+ this.reason = reason;
74
+ this.name = 'OrgPostgresMigrationBackupMissingError';
75
+ }
76
+ }
62
77
  async function withinTimeout(call) {
63
78
  let timer;
64
79
  try {
@@ -224,6 +239,29 @@ export async function requireFreshBackupForSigninMove(ports) {
224
239
  throw new SigninDbMoveBackupMissingError('no-backup');
225
240
  return fresh.uuid;
226
241
  }
242
+ /**
243
+ * The org Postgres migration's gate (#444): the old database of `home` needs a schedule and a `success`
244
+ * execution not older than {@link BACKUP_MAX_AGE_HOURS} hours, read from Coolify's executions. A schedule
245
+ * alone proves no dump ran. Returns the uuid of the execution that passed.
246
+ */
247
+ export async function requireFreshBackupForOrgPostgresMigration(ports, legacyDatabaseKey, backupName) {
248
+ const now = (ports.now ?? Date.now)();
249
+ const databaseUuid = ports.recordedIds[legacyDatabaseKey];
250
+ const backupUuid = ports.recordedIds[backupScopeKey(backupName)];
251
+ if (!databaseUuid || !backupUuid)
252
+ throw new OrgPostgresMigrationBackupMissingError('no-backup');
253
+ let executions;
254
+ try {
255
+ executions = await readExecutions(ports.coolify, databaseUuid, backupUuid);
256
+ }
257
+ catch {
258
+ throw new OrgPostgresMigrationBackupMissingError('no-backup');
259
+ }
260
+ const fresh = executions ? freshSuccess(executions, now) : null;
261
+ if (!fresh)
262
+ throw new OrgPostgresMigrationBackupMissingError('no-backup');
263
+ return fresh.uuid;
264
+ }
227
265
  /**
228
266
  * The gate for a move to the company's own network, which deletes every database of the project. It
229
267
  * fails closed and is stricter than the upgrade gate: it reads the project itself, not foundry's records.
@@ -0,0 +1,12 @@
1
+ import type { BouleOwnersPort } from './executor.js';
2
+ /** Thrown for any answer or failure of Boule's owner-seeding door. Carries the HTTP status (0 for no answer) and nothing else, so no secret or body can reach a message. */
3
+ export declare class BouleOwnersError extends Error {
4
+ readonly status: number;
5
+ constructor(status: number);
6
+ }
7
+ /**
8
+ * The real {@link BouleOwnersPort}: `POST https://<hostname>/v1/organisations/<id>/owners/seed`
9
+ * with the secret as a bearer token. 10 s per call, no redirect, three tries on a
10
+ * network error or a 5xx answer. Any other answer ends at once.
11
+ */
12
+ export declare function httpBouleOwners(fetchImpl?: typeof fetch): BouleOwnersPort;
@@ -0,0 +1,59 @@
1
+ import { z } from 'zod';
2
+ /** Thrown for any answer or failure of Boule's owner-seeding door. Carries the HTTP status (0 for no answer) and nothing else, so no secret or body can reach a message. */
3
+ export class BouleOwnersError extends Error {
4
+ status;
5
+ constructor(status) {
6
+ super(`Boule's owner-seeding door ${status === 0 ? 'did not answer' : `answered ${status}`}`);
7
+ this.status = status;
8
+ this.name = 'BouleOwnersError';
9
+ }
10
+ }
11
+ const resultsSchema = z.object({
12
+ results: z.array(z.object({
13
+ subject: z.string(),
14
+ outcome: z.enum(['created', 'exists', 'refused']),
15
+ filled: z.array(z.string()),
16
+ code: z.string().optional(),
17
+ })),
18
+ });
19
+ const TIMEOUT_MS = 10_000;
20
+ const ATTEMPTS = 3;
21
+ /**
22
+ * The real {@link BouleOwnersPort}: `POST https://<hostname>/v1/organisations/<id>/owners/seed`
23
+ * with the secret as a bearer token. 10 s per call, no redirect, three tries on a
24
+ * network error or a 5xx answer. Any other answer ends at once.
25
+ */
26
+ export function httpBouleOwners(fetchImpl = fetch) {
27
+ return {
28
+ async seed({ hostname, organisationId, secret, owners }) {
29
+ const url = `https://${hostname}/v1/organisations/${encodeURIComponent(organisationId)}/owners/seed`;
30
+ let status = 0;
31
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
32
+ let response;
33
+ try {
34
+ response = await fetchImpl(url, {
35
+ method: 'POST',
36
+ headers: { authorization: `Bearer ${secret}`, 'content-type': 'application/json' },
37
+ body: JSON.stringify({ owners }),
38
+ redirect: 'error',
39
+ signal: AbortSignal.timeout(TIMEOUT_MS),
40
+ });
41
+ }
42
+ catch {
43
+ status = 0;
44
+ continue;
45
+ }
46
+ status = response.status;
47
+ if (status >= 500)
48
+ continue;
49
+ if (status !== 200)
50
+ throw new BouleOwnersError(status);
51
+ const parsed = resultsSchema.safeParse(await response.json().catch(() => undefined));
52
+ if (!parsed.success)
53
+ throw new BouleOwnersError(status);
54
+ return parsed.data;
55
+ }
56
+ throw new BouleOwnersError(status);
57
+ },
58
+ };
59
+ }
@@ -1,4 +1,4 @@
1
- import { type DeclaredOrg } from '../org.js';
1
+ import { type DeclaredOrg, type OwnerIdentity } from '../org.js';
2
2
  import type { BrokerEntryInput, BrokerEntryWant } from './broker-entries.js';
3
3
  /**
4
4
  * #161 (Boule part): what both the planner (`diff.ts`) and the executor
@@ -77,6 +77,12 @@ export declare const BOULE_PEER_PASSWORD_ENV = "AUTHZ_PEER_PASSWORD_OPERATOR";
77
77
  * env; compared in memory like {@link BOULE_PEER_PASSWORD_ENV}.
78
78
  */
79
79
  export declare const BOULE_BRIDGE_SECRET_ENV = "ARCHON_BRIDGE_SECRET";
80
+ /**
81
+ * The secret foundry sends to Boule's owner-seeding door as a bearer token.
82
+ * Minted once for every org's Boule and held only in that Boule's env; foundry
83
+ * reads it back from there when it seeds. Not in {@link desiredBouleEnv}.
84
+ */
85
+ export declare const BOULE_OWNER_SEED_SECRET_ENV = "OWNER_SEED_SECRET";
80
86
  /** The role Boule creates from {@link BOULE_PEER_PASSWORD_ENV}, with grants on schema `authz` only. */
81
87
  export declare const BOULE_PEER_ROLE = "authz_operator_rt";
82
88
  /** The org's Archon id and the credential Boule reaches Archon with (C3). The id is the org's own; the credential is a secret. */
@@ -120,7 +126,7 @@ export interface BouleCredentialWants {
120
126
  archonRenewDue?: boolean;
121
127
  }
122
128
  /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
123
- export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "AUTHZ_FIRST_OWNER", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN", "AUTHZ_PEER_PASSWORD_OPERATOR", "ARCHON_BRIDGE_SECRET", "ARCHON_ORG_ID", "ARCHON_CREDENTIAL", "CUSTOMERS_ORGANISATION_ID", "CUSTOMERS_DIRECTORY_CREDENTIAL", "SIGNIN_UPSTREAM"];
129
+ export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "AUTHZ_FIRST_OWNER", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN", "AUTHZ_PEER_PASSWORD_OPERATOR", "ARCHON_BRIDGE_SECRET", "OWNER_SEED_SECRET", "ARCHON_ORG_ID", "ARCHON_CREDENTIAL", "CUSTOMERS_ORGANISATION_ID", "CUSTOMERS_DIRECTORY_CREDENTIAL", "SIGNIN_UPSTREAM"];
124
130
  /**
125
131
  * The env vars an org's Boule app should carry, and every input still missing
126
132
  * to build them. Callers that write must refuse on a non-empty `missing`: a
@@ -169,6 +175,36 @@ export declare function wantedBouleBrokerEntry(org: DeclaredOrg, recorded: Reado
169
175
  * except `SIGNIN_UPSTREAM`: the switch turned off must take it away, so a live one that is not desired is drift.
170
176
  */
171
177
  export declare function bouleEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
178
+ /** Scope id per owner once Boule answered created or exists: `boule_owner_seeded.<email lowercased>`, value '1'. */
179
+ export declare function bouleOwnerSeededScopeKey(email: string): string;
180
+ /** Scope id per owner Boule refused (`holds_other_role`, `platform_frozen`): the planner reports it blocked and sends nothing more. */
181
+ export declare function bouleOwnerRefusedScopeKey(email: string): string;
182
+ /** The ZITADEL user id foundry recorded for a declared owner, or undefined. */
183
+ export declare function bouleOwnerSubject(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, owner: OwnerIdentity): string | undefined;
184
+ /** Owners still to seed, in declared order: neither seeded nor refused yet. */
185
+ export declare function bouleOwnersToSeed(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): OwnerIdentity[];
186
+ /** One owner as Boule's owner-seeding door takes it. */
187
+ export interface BouleOwnerSeed {
188
+ subject: string;
189
+ givenName: string;
190
+ familyName: string;
191
+ email: string;
192
+ mailbox: string | null;
193
+ roleAtCompany: string | null;
194
+ phone: string | null;
195
+ }
196
+ /** The body's owners for the unseeded owners that have a recorded ZITADEL user id, in declared order. */
197
+ export declare function bouleOwnerSeeds(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): BouleOwnerSeed[];
198
+ /**
199
+ * The step that seeds a company's declared owners into its Boule, or the lines
200
+ * for owners that cannot be seeded yet (blocked, not failing). Planned only
201
+ * once Boule is deployed and holds the seeding secret; before that the Boule
202
+ * step runs first and the next plan includes this one.
203
+ */
204
+ export declare function planBouleOwners(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, liveBouleEnv: Readonly<Record<string, string>> | null): {
205
+ seed: BouleOwnerSeed[];
206
+ blocked: string[];
207
+ };
172
208
  /**
173
209
  * The customers env a live Boule still holds after the company switched customers off. A host that merely
174
210
  * cannot deliver the credential keeps what it has: only an explicit off removes it.
@@ -1,4 +1,4 @@
1
- import { DEFAULT_NO_REPLY_LOCAL_PART } from '../org.js';
1
+ import { DEFAULT_NO_REPLY_LOCAL_PART, ownerMailboxes, } from '../org.js';
2
2
  import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
3
3
  import { SIGN_IN_UPSTREAM_ENV, inAppHostApplied, signInUpstream, staleSignInUpstream, } from './sign-in.js';
4
4
  /**
@@ -88,6 +88,12 @@ export const BOULE_PEER_PASSWORD_ENV = 'AUTHZ_PEER_PASSWORD_OPERATOR';
88
88
  * env; compared in memory like {@link BOULE_PEER_PASSWORD_ENV}.
89
89
  */
90
90
  export const BOULE_BRIDGE_SECRET_ENV = 'ARCHON_BRIDGE_SECRET';
91
+ /**
92
+ * The secret foundry sends to Boule's owner-seeding door as a bearer token.
93
+ * Minted once for every org's Boule and held only in that Boule's env; foundry
94
+ * reads it back from there when it seeds. Not in {@link desiredBouleEnv}.
95
+ */
96
+ export const BOULE_OWNER_SEED_SECRET_ENV = 'OWNER_SEED_SECRET';
91
97
  /** The role Boule creates from {@link BOULE_PEER_PASSWORD_ENV}, with grants on schema `authz` only. */
92
98
  export const BOULE_PEER_ROLE = 'authz_operator_rt';
93
99
  /** The org's Archon id and the credential Boule reaches Archon with (C3). The id is the org's own; the credential is a secret. */
@@ -136,6 +142,7 @@ export const BOULE_MANAGED_ENV_KEYS = [
136
142
  BOULE_MAIL_TOKEN_ENV,
137
143
  BOULE_PEER_PASSWORD_ENV,
138
144
  BOULE_BRIDGE_SECRET_ENV,
145
+ BOULE_OWNER_SEED_SECRET_ENV,
139
146
  BOULE_ARCHON_ORG_ID_ENV,
140
147
  BOULE_ARCHON_CREDENTIAL_ENV,
141
148
  BOULE_CUSTOMERS_ORG_ENV,
@@ -261,6 +268,70 @@ export function bouleEnvDrift(desired, live) {
261
268
  drift.push(SIGN_IN_UPSTREAM_ENV);
262
269
  return drift;
263
270
  }
271
+ /** Scope id per owner once Boule answered created or exists: `boule_owner_seeded.<email lowercased>`, value '1'. */
272
+ export function bouleOwnerSeededScopeKey(email) {
273
+ return `boule_owner_seeded.${email.toLowerCase()}`;
274
+ }
275
+ /** Scope id per owner Boule refused (`holds_other_role`, `platform_frozen`): the planner reports it blocked and sends nothing more. */
276
+ export function bouleOwnerRefusedScopeKey(email) {
277
+ return `boule_owner_refused.${email.toLowerCase()}`;
278
+ }
279
+ /** The ZITADEL user id foundry recorded for a declared owner, or undefined. */
280
+ export function bouleOwnerSubject(org, recorded, owner) {
281
+ return recorded[`zitadel:owner:${org.id}:${owner.email.toLowerCase()}`] || undefined;
282
+ }
283
+ /** Owners still to seed, in declared order: neither seeded nor refused yet. */
284
+ export function bouleOwnersToSeed(org, recorded) {
285
+ return org.owners.filter((owner) => !recorded[bouleOwnerSeededScopeKey(owner.email)] &&
286
+ !recorded[bouleOwnerRefusedScopeKey(owner.email)]);
287
+ }
288
+ /** The body's owners for the unseeded owners that have a recorded ZITADEL user id, in declared order. */
289
+ export function bouleOwnerSeeds(org, recorded) {
290
+ const boxes = ownerMailboxes(org);
291
+ const seeds = [];
292
+ for (const owner of bouleOwnersToSeed(org, recorded)) {
293
+ const subject = bouleOwnerSubject(org, recorded, owner);
294
+ if (!subject)
295
+ continue;
296
+ seeds.push({
297
+ subject,
298
+ givenName: owner.givenName,
299
+ familyName: owner.familyName,
300
+ email: owner.email,
301
+ mailbox: boxes.find((b) => b.owner === owner)?.address ?? null,
302
+ roleAtCompany: owner.roleAtCompany ?? null,
303
+ phone: owner.phone ?? null,
304
+ });
305
+ }
306
+ return seeds;
307
+ }
308
+ /**
309
+ * The step that seeds a company's declared owners into its Boule, or the lines
310
+ * for owners that cannot be seeded yet (blocked, not failing). Planned only
311
+ * once Boule is deployed and holds the seeding secret; before that the Boule
312
+ * step runs first and the next plan includes this one.
313
+ */
314
+ export function planBouleOwners(org, recorded, liveBouleEnv) {
315
+ const none = { seed: [], blocked: [] };
316
+ if (org.owners.length === 0)
317
+ return none;
318
+ if (!recorded[BOULE_DEPLOYED_SCOPE_KEY])
319
+ return none;
320
+ if (!liveBouleEnv?.[BOULE_OWNER_SEED_SECRET_ENV])
321
+ return none;
322
+ const blocked = [];
323
+ org.owners.forEach((owner, index) => {
324
+ const at = `${org.slug}'s Boule owners are blocked, not planned: owner ${index + 1}`;
325
+ if (recorded[bouleOwnerRefusedScopeKey(owner.email)]) {
326
+ blocked.push(`${at} was refused by Boule (it holds another role or the platform is frozen)`);
327
+ }
328
+ else if (!recorded[bouleOwnerSeededScopeKey(owner.email)] &&
329
+ !bouleOwnerSubject(org, recorded, owner)) {
330
+ blocked.push(`${at} has no sign-in account yet`);
331
+ }
332
+ });
333
+ return { seed: bouleOwnerSeeds(org, recorded), blocked };
334
+ }
264
335
  /**
265
336
  * The customers env a live Boule still holds after the company switched customers off. A host that merely
266
337
  * cannot deliver the credential keeps what it has: only an explicit off removes it.
@@ -1,4 +1,4 @@
1
- import type { DeclaredOrg } from '../org.js';
1
+ import { type DeclaredOrg } from '../org.js';
2
2
  import type { UnmanagedItem } from '../plan.js';
3
3
  import type { DraftStep } from './diff.js';
4
4
  /**
@@ -11,6 +11,8 @@ import type { DraftStep } from './diff.js';
11
11
  * `app` is the hosting entry's database, recorded as a resource id.
12
12
  */
13
13
  export declare function companyDatabaseKeys(org: Pick<DeclaredOrg, 'id'>): Readonly<Record<string, string>>;
14
+ /** The {@link companyDatabaseKeys} name of each home's legacy Coolify database, which its backup schedule is recorded under. */
15
+ export declare const LEGACY_BACKUP_NAMES: Readonly<Record<string, string>>;
14
16
  /**
15
17
  * The database a company backup schedule must name when the Coolify resource
16
18
  * is not left to pick its first database. Absent: the resource's first database.
@@ -20,6 +22,10 @@ export declare const COMPANY_BACKUP_DATABASE_NAMES: Readonly<Record<string, stri
20
22
  export declare const BACKUP_SCOPE_PREFIX = "backup_";
21
23
  /** The recorded-id key a company database's backup schedule uuid is stored under. */
22
24
  export declare function backupScopeKey(db: string): string;
25
+ /** The recorded-id key that holds the database list the company server's schedule was last set to. */
26
+ export declare const BACKUP_POSTGRES_DATABASES_SCOPE_KEY = "backup_postgres_databases";
27
+ /** Comma-separated `databases_to_backup` for the server schedule: every home on the server, sorted. */
28
+ export declare function serverBackupDatabases(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): string;
23
29
  /** The databases an upgrade of `service` can migrate, by the names in {@link companyDatabaseKeys}. Empty: not coverable here. */
24
30
  export declare function databasesForService(service: string): readonly string[] | 'all';
25
31
  /**
@@ -1,6 +1,8 @@
1
+ import { ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, orgServiceDatabase, } from '../org.js';
1
2
  import { BOULE_DATABASE_SCOPE_KEY } from './boule.js';
2
3
  import { CHAT_REKEY_MAS_DB_SCOPE_KEY, CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY } from './executor.js';
3
4
  import { MAIL_DATABASE_NAME, MAIL_DATABASE_SCOPE_KEY } from './mail-store.js';
5
+ import { serviceOnServer } from './org-postgres.js';
4
6
  import { signinDbBackupMissing } from './signin-db-backup.js';
5
7
  import { SIGNIN_DB_TLS_SCOPE_KEY } from './signin-db-tls.js';
6
8
  /**
@@ -29,8 +31,21 @@ export function companyDatabaseKeys(org) {
29
31
  boule: BOULE_DATABASE_SCOPE_KEY,
30
32
  chat_rekey_synapse: CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY,
31
33
  chat_rekey_mas: CHAT_REKEY_MAS_DB_SCOPE_KEY,
34
+ postgres: ORG_POSTGRES_SERVER_SCOPE_KEY,
32
35
  };
33
36
  }
37
+ /** The {@link companyDatabaseKeys} name of each home's legacy Coolify database, which its backup schedule is recorded under. */
38
+ export const LEGACY_BACKUP_NAMES = {
39
+ app: 'app',
40
+ cms: 'cms',
41
+ boule: 'boule',
42
+ chat_service: 'chat',
43
+ calls_service: 'calls',
44
+ chat_synapse: 'chat_synapse',
45
+ chat_mas: 'chat_mas',
46
+ calls_synapse: 'synapse',
47
+ calls_mas: 'mas',
48
+ };
34
49
  /**
35
50
  * The database a company backup schedule must name when the Coolify resource
36
51
  * is not left to pick its first database. Absent: the resource's first database.
@@ -44,6 +59,15 @@ export const BACKUP_SCOPE_PREFIX = 'backup_';
44
59
  export function backupScopeKey(db) {
45
60
  return `${BACKUP_SCOPE_PREFIX}${db}`;
46
61
  }
62
+ /** The recorded-id key that holds the database list the company server's schedule was last set to. */
63
+ export const BACKUP_POSTGRES_DATABASES_SCOPE_KEY = 'backup_postgres_databases';
64
+ /** Comma-separated `databases_to_backup` for the server schedule: every home on the server, sorted. */
65
+ export function serverBackupDatabases(org, recordedIds) {
66
+ return ORG_SERVICE_SCHEMAS.filter((home) => serviceOnServer(org, home, recordedIds))
67
+ .map((home) => orgServiceDatabase(org, home).database)
68
+ .sort()
69
+ .join(',');
70
+ }
47
71
  /** The databases an upgrade of `service` can migrate, by the names in {@link companyDatabaseKeys}. Empty: not coverable here. */
48
72
  export function databasesForService(service) {
49
73
  switch (service) {
@@ -80,7 +104,18 @@ export function planCompanyBackups(org, recordedIds, storageUuid) {
80
104
  return { steps, blocked };
81
105
  for (const [db, key] of Object.entries(companyDatabaseKeys(org))) {
82
106
  const databaseId = recordedIds[key];
83
- if (!databaseId || recordedIds[backupScopeKey(db)])
107
+ if (!databaseId)
108
+ continue;
109
+ let databaseNames;
110
+ if (db === 'postgres') {
111
+ databaseNames = serverBackupDatabases(org, recordedIds);
112
+ if (!databaseNames)
113
+ continue;
114
+ if (recordedIds[backupScopeKey(db)] &&
115
+ recordedIds[BACKUP_POSTGRES_DATABASES_SCOPE_KEY] === databaseNames)
116
+ continue;
117
+ }
118
+ else if (recordedIds[backupScopeKey(db)])
84
119
  continue;
85
120
  if (!storageUuid) {
86
121
  blocked.push({
@@ -94,7 +129,12 @@ export function planCompanyBackups(org, recordedIds, storageUuid) {
94
129
  system: 'coolify',
95
130
  kind: 'backup',
96
131
  key: `${org.slug}:${db}`,
97
- desired: { databaseId, database: db, storageUuid },
132
+ desired: {
133
+ databaseId,
134
+ database: db,
135
+ storageUuid,
136
+ ...(databaseNames ? { databaseNames } : {}),
137
+ },
98
138
  },
99
139
  step: {
100
140
  description: `schedule a nightly backup of ${org.slug}'s ${db} database to the platform's backup bucket`,
@@ -0,0 +1,41 @@
1
+ import { type DeclaredOrg } from '../org.js';
2
+ /** The auth release that holds both the shared-host organisation check (577) and the session binding (795). */
3
+ export declare const MIN_AUTH_FOR_SESSION_BINDING = "0.28.0";
4
+ export declare const MIN_AUTH_FOR_CLIENT_ORGS = "0.28.0";
5
+ /**
6
+ * Scope id set to `'1'` once `LOGIN_SESSION_BIND_KEY` is live on the in-app login app (written, and the
7
+ * app deployed after it). No binding carries a cookie domain before it.
8
+ */
9
+ export declare const SIGN_IN_BIND_KEY_SCOPE_KEY = "sign_in_bind_key_ready";
10
+ /** The env that holds the key: at least 32 characters, secret, on the in-app login app only. */
11
+ export declare const SIGN_IN_BIND_KEY_ENV = "LOGIN_SESSION_BIND_KEY";
12
+ export declare const SIGN_IN_BIND_KEY_MIN_LENGTH = 32;
13
+ /** The cookie domain the app-host bindings were last written with (`''` for none). */
14
+ export declare const LOGIN_COOKIE_DOMAIN_SCOPE_KEY = "login_cookie_domain";
15
+ /**
16
+ * True only for a `vX.Y.Z` tag at or above `min`. A branch, a sha or anything else is false, so a
17
+ * platform on `main` never passes a gate that needs a release.
18
+ */
19
+ export declare function authAtLeast(gitRef: string, min: string): boolean;
20
+ export type CookieDomainDecision = {
21
+ domain: string;
22
+ } | {
23
+ domain: null;
24
+ reason: string;
25
+ };
26
+ /** Reasons that wait on a release or a key; the planner reports these. The others are the declaration's own shape. */
27
+ export declare const COOKIE_DOMAIN_WAITING_REASONS: Set<string>;
28
+ /**
29
+ * The company-wide cookie domain for an org's app-host bindings, or why there is none. A domain is
30
+ * returned only when the sign-in is in-app with app hosts bound, the org has its own domain, nothing
31
+ * but estate apps is public under it, the login is a release that binds sessions to the host, and the
32
+ * bind key is live. A company on a shared domain never gets one: a cookie there would span companies.
33
+ */
34
+ export declare function cookieDomainAllowed(input: {
35
+ org: DeclaredOrg;
36
+ recorded: Readonly<Record<string, string>>;
37
+ authGitRef: string;
38
+ bindKeyReady: boolean;
39
+ }): CookieDomainDecision;
40
+ /** The fixed line the planner reports while a cookie domain waits on the login. */
41
+ export declare function cookieDomainWaitingLine(slug: string, reason: string): string;
@@ -0,0 +1,71 @@
1
+ import { ownsServices } from '../org.js';
2
+ import { inAppApps } from './login-host.js';
3
+ /** The auth release that holds both the shared-host organisation check (577) and the session binding (795). */
4
+ export const MIN_AUTH_FOR_SESSION_BINDING = '0.28.0';
5
+ export const MIN_AUTH_FOR_CLIENT_ORGS = '0.28.0';
6
+ /**
7
+ * Scope id set to `'1'` once `LOGIN_SESSION_BIND_KEY` is live on the in-app login app (written, and the
8
+ * app deployed after it). No binding carries a cookie domain before it.
9
+ */
10
+ export const SIGN_IN_BIND_KEY_SCOPE_KEY = 'sign_in_bind_key_ready';
11
+ /** The env that holds the key: at least 32 characters, secret, on the in-app login app only. */
12
+ export const SIGN_IN_BIND_KEY_ENV = 'LOGIN_SESSION_BIND_KEY';
13
+ export const SIGN_IN_BIND_KEY_MIN_LENGTH = 32;
14
+ /** The cookie domain the app-host bindings were last written with (`''` for none). */
15
+ export const LOGIN_COOKIE_DOMAIN_SCOPE_KEY = 'login_cookie_domain';
16
+ const TAG = /^v(\d+)\.(\d+)\.(\d+)$/;
17
+ /**
18
+ * True only for a `vX.Y.Z` tag at or above `min`. A branch, a sha or anything else is false, so a
19
+ * platform on `main` never passes a gate that needs a release.
20
+ */
21
+ export function authAtLeast(gitRef, min) {
22
+ const have = TAG.exec(gitRef);
23
+ const want = /^(\d+)\.(\d+)\.(\d+)$/.exec(min);
24
+ if (!have || !want)
25
+ return false;
26
+ for (let i = 1; i <= 3; i++) {
27
+ const a = Number(have[i]);
28
+ const b = Number(want[i]);
29
+ if (a !== b)
30
+ return a > b;
31
+ }
32
+ return true;
33
+ }
34
+ /** Reasons that wait on a release or a key; the planner reports these. The others are the declaration's own shape. */
35
+ export const COOKIE_DOMAIN_WAITING_REASONS = new Set([
36
+ 'auth below 0.28.0',
37
+ 'the session bind key is not live on the in-app login',
38
+ ]);
39
+ /**
40
+ * The company-wide cookie domain for an org's app-host bindings, or why there is none. A domain is
41
+ * returned only when the sign-in is in-app with app hosts bound, the org has its own domain, nothing
42
+ * but estate apps is public under it, the login is a release that binds sessions to the host, and the
43
+ * bind key is live. A company on a shared domain never gets one: a cookie there would span companies.
44
+ */
45
+ export function cookieDomainAllowed(input) {
46
+ const { org, recorded } = input;
47
+ if (inAppApps(org, recorded).length === 0) {
48
+ return { domain: null, reason: 'in-app sign-in has no app host bound' };
49
+ }
50
+ if (!org.domain)
51
+ return { domain: null, reason: 'the company has no domain of its own' };
52
+ // An own org's in-app login app is not given the key by the platform.
53
+ if (ownsServices(org)) {
54
+ return { domain: null, reason: 'the company runs its own sign-in' };
55
+ }
56
+ const publicApps = org.hosting.entries.some((e) => e.hostnames.length > 0 || e.worker !== undefined || e.workerApp !== undefined);
57
+ if (publicApps) {
58
+ return { domain: null, reason: 'a hosted app is public under the company domain' };
59
+ }
60
+ if (!authAtLeast(input.authGitRef, MIN_AUTH_FOR_SESSION_BINDING)) {
61
+ return { domain: null, reason: 'auth below 0.28.0' };
62
+ }
63
+ if (!input.bindKeyReady) {
64
+ return { domain: null, reason: 'the session bind key is not live on the in-app login' };
65
+ }
66
+ return { domain: org.domain };
67
+ }
68
+ /** The fixed line the planner reports while a cookie domain waits on the login. */
69
+ export function cookieDomainWaitingLine(slug, reason) {
70
+ return `${slug}'s company-wide sign-in cookie waits: ${reason}`;
71
+ }
@@ -0,0 +1,58 @@
1
+ import { type DeclaredOrg } from '../org.js';
2
+ import { type AgoraHostValues, DESKTOP_AUTH_APP_SCOPE_KEY, DESKTOP_AUTH_DEPLOYED_SCOPE_KEY, DESKTOP_AUTH_PORT, desktopAuthInternalName, desktopAuthUpstream } from './agora.js';
3
+ import type { DraftStep } from './diff.js';
4
+ export { DESKTOP_AUTH_APP_SCOPE_KEY, DESKTOP_AUTH_DEPLOYED_SCOPE_KEY, DESKTOP_AUTH_PORT, desktopAuthInternalName, desktopAuthUpstream, };
5
+ /**
6
+ * #440: Agora's desktop sign-in backend (`apps/desktop-auth` of the agora repo), one internal app per
7
+ * company on its Postgres server's `desktop_auth` home. Agora proxies `/auth/desktop/*` to it. Its own
8
+ * secrets (cookie, handoff key) live in its env only and are generated once.
9
+ */
10
+ export declare const DESKTOP_AUTH_DOCKERFILE = "/apps/desktop-auth/Dockerfile";
11
+ export declare const DESKTOP_AUTH_HOME = "desktop_auth";
12
+ /** The digest of the non-secret env last written; a changed hostname, issuer or id plans the step again. */
13
+ export declare const DESKTOP_AUTH_REVISION_SCOPE_KEY = "coolify_desktop_auth_revision";
14
+ /** The Agora origin the app was last written for (CLAUDE.md rule 3: the applied hostname is a scope id). */
15
+ export declare const DESKTOP_AUTH_ORIGIN_SCOPE_KEY = "coolify_desktop_auth_origin";
16
+ /** The callback URI last put on Agora's client, or `unsupported:<uri>` when the providers client cannot. */
17
+ export declare const DESKTOP_AUTH_REDIRECT_SCOPE_KEY = "coolify_desktop_auth_redirect";
18
+ /** Recorded once the readiness probe through Agora answered. */
19
+ export declare const DESKTOP_AUTH_READY_SCOPE_KEY = "coolify_desktop_auth_ready";
20
+ /** `https://<agora host>/auth/desktop/callback`. */
21
+ export declare function desktopAuthCallbackUri(agoraOrigin: string): string;
22
+ export declare const desktopAuthOrigin: (org: DeclaredOrg) => string;
23
+ /** The route desktop-auth answers 200 on without a session; foundry probes it through Agora. */
24
+ export declare const DESKTOP_AUTH_READY_PATH = "/auth/desktop/error";
25
+ export declare const desktopAuthRedirectUnsupported: (uri: string) => string;
26
+ /** The values both the plan and the executor derive the env from. */
27
+ export interface DesktopAuthInputs {
28
+ origin: string;
29
+ issuer: string;
30
+ clientId: string;
31
+ organisationId: string;
32
+ projectId: string;
33
+ mailProjectId: string;
34
+ filesProjectId: string | null;
35
+ }
36
+ /** The inputs from the host values and recorded ids, and the names of those still missing. */
37
+ export declare function desktopAuthInputs(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, rawHost: AgoraHostValues): {
38
+ inputs: DesktopAuthInputs | null;
39
+ missing: string[];
40
+ };
41
+ /** The env that is not a secret and not a connection URL. `APP_URL` is the origin with its slash: the app refuses any other form. */
42
+ export declare function desktopAuthEnv(inputs: DesktopAuthInputs, runtimeRole: string): Record<string, string>;
43
+ export declare function desktopAuthRevision(env: Readonly<Record<string, string>>): string;
44
+ /**
45
+ * The `coolify:desktop-auth` step. Planned when the app is not recorded, when the env it was written for
46
+ * differs (a rename moves the origin), when it never ran healthy, when the callback URI is not on Agora's
47
+ * client, and when Agora carries the upstream but the readiness probe never answered. Not planned while
48
+ * Agora is blocked (its own line says why) or while the `desktop_auth` home cannot exist yet (blocked).
49
+ * `homePlanned` is true when the databases step in this plan creates the home.
50
+ */
51
+ export declare function planDesktopAuth(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, input: {
52
+ host: AgoraHostValues | null;
53
+ liveAgoraEnv: Readonly<Record<string, string>> | null;
54
+ homePlanned: boolean;
55
+ }): {
56
+ step: DraftStep | null;
57
+ blocked: string | null;
58
+ };