@wtfalch/foundry 0.45.1 → 0.46.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 (60) hide show
  1. package/dist/index.d.ts +4 -2
  2. package/dist/index.js +2 -1
  3. package/dist/org.d.ts +37 -0
  4. package/dist/org.js +135 -10
  5. package/dist/plan/agora.d.ts +14 -0
  6. package/dist/plan/agora.js +21 -0
  7. package/dist/plan/backup-gate.d.ts +15 -0
  8. package/dist/plan/backup-gate.js +38 -0
  9. package/dist/plan/boule-owners-http.d.ts +12 -0
  10. package/dist/plan/boule-owners-http.js +59 -0
  11. package/dist/plan/boule.d.ts +38 -2
  12. package/dist/plan/boule.js +72 -1
  13. package/dist/plan/company-backups.d.ts +7 -1
  14. package/dist/plan/company-backups.js +42 -2
  15. package/dist/plan/cookie-domain.d.ts +41 -0
  16. package/dist/plan/cookie-domain.js +71 -0
  17. package/dist/plan/desktop-auth.d.ts +58 -0
  18. package/dist/plan/desktop-auth.js +144 -0
  19. package/dist/plan/desktop-callback.d.ts +26 -0
  20. package/dist/plan/desktop-callback.js +59 -0
  21. package/dist/plan/diff.d.ts +13 -4
  22. package/dist/plan/diff.js +272 -84
  23. package/dist/plan/executor.d.ts +136 -6
  24. package/dist/plan/executor.js +725 -98
  25. package/dist/plan/health-gaps.js +5 -0
  26. package/dist/plan/index.d.ts +3 -0
  27. package/dist/plan/index.js +2 -0
  28. package/dist/plan/job-app.d.ts +23 -0
  29. package/dist/plan/job-app.js +41 -0
  30. package/dist/plan/live.d.ts +6 -0
  31. package/dist/plan/live.js +16 -0
  32. package/dist/plan/login-host.d.ts +28 -0
  33. package/dist/plan/login-host.js +36 -0
  34. package/dist/plan/org-names.js +9 -1
  35. package/dist/plan/org-postgres-job.d.ts +22 -0
  36. package/dist/plan/org-postgres-job.js +91 -0
  37. package/dist/plan/org-postgres-migrate.d.ts +115 -0
  38. package/dist/plan/org-postgres-migrate.js +466 -0
  39. package/dist/plan/org-postgres.d.ts +90 -0
  40. package/dist/plan/org-postgres.js +293 -0
  41. package/dist/plan/plan-apply.d.ts +30 -2
  42. package/dist/plan/plan-apply.js +104 -7
  43. package/dist/plan/platform-diff.d.ts +45 -0
  44. package/dist/plan/platform-diff.js +159 -4
  45. package/dist/plan/platform-org.d.ts +2 -0
  46. package/dist/plan/platform-org.js +4 -0
  47. package/dist/plan/platform-providers.d.ts +12 -2
  48. package/dist/plan/platform-providers.js +279 -9
  49. package/dist/plan/post-logout-uris.d.ts +22 -0
  50. package/dist/plan/post-logout-uris.js +43 -0
  51. package/dist/plan/providers-readers.d.ts +10 -1
  52. package/dist/plan/providers-readers.js +41 -1
  53. package/dist/plan/sign-in-bindings.d.ts +4 -0
  54. package/dist/plan/sign-in-bindings.js +10 -0
  55. package/dist/plan/sign-in-org.d.ts +19 -6
  56. package/dist/plan/sign-in-org.js +35 -10
  57. package/dist/plan/sign-in.d.ts +12 -5
  58. package/dist/plan/sign-in.js +12 -5
  59. package/dist/plan/signin-db-move.js +10 -34
  60. package/package.json +5 -5
@@ -32,6 +32,11 @@ export const EXIT_JOBS = [
32
32
  export const REPO_DOCKERFILE_CHECKED = [
33
33
  { repo: 'wtfalch/auth', dockerfile: '/login/Dockerfile', seen: 'login image, docker inspect' },
34
34
  { repo: 'wtfalch/auth', dockerfile: '/broker/Dockerfile', seen: 'broker image, docker inspect' },
35
+ {
36
+ repo: 'wtfalch/agora',
37
+ dockerfile: '/apps/desktop-auth/Dockerfile',
38
+ seen: 'apps/desktop-auth/Dockerfile HEALTHCHECK line (calls /health)',
39
+ },
35
40
  { repo: 'wtfalch/ai', target: 'worker', seen: 'ai-worker image, docker inspect' },
36
41
  { repo: 'wtfalch/integrations', target: 'runner', seen: 'integrations/Dockerfile line 49' },
37
42
  {
@@ -20,6 +20,9 @@ export type { PlatformOrgClientFactories } from './platform-org-ports.js';
20
20
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
21
21
  export type { AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot } from './adopt.js';
22
22
  export { AdoptRefusedError } from './adopt.js';
23
+ export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationNotConfirmedError, } from './org-postgres-migrate.js';
24
+ export type { OrgPostgresMigrationConfirmation } from './org-postgres-migrate.js';
25
+ export { OrgPostgresMigrationBackupMissingError } from './backup-gate.js';
23
26
  export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, FoundryApplyOptions, } from './plan-apply.js';
24
27
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
25
28
  export type { UnconfirmedUpgrade } from './confirm-upgrade.js';
@@ -9,6 +9,8 @@ export { provisionSfuApp, sfuCompose, sfuConfig } from './own-sfu.js';
9
9
  export { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgNetwork, orgTarget, platformNetwork, platformTarget, scopeFromOrg, scopeFromPlatform, } from './own-services.js';
10
10
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
11
11
  export { AdoptRefusedError } from './adopt.js';
12
+ export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationNotConfirmedError, } from './org-postgres-migrate.js';
13
+ export { OrgPostgresMigrationBackupMissingError } from './backup-gate.js';
12
14
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
13
15
  export { MAIL_STORE_SWITCH_SERVICE } from './mail-store.js';
14
16
  export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './backup-gate.js';
@@ -0,0 +1,23 @@
1
+ import type { PlatformAuthDeps } from './platform-providers.js';
2
+ /** A failure a job step words itself. Anything else that goes wrong is reported without its cause, which could carry a URL. */
3
+ export declare class JobFailure extends Error {
4
+ }
5
+ export interface JobRuntime {
6
+ sleep?: (ms: number) => Promise<void>;
7
+ now?: () => number;
8
+ /** How long the job may run (default 30 minutes). */
9
+ signinDbMoveTimeoutMs?: number;
10
+ /** Pause between polls of the job (default 10 seconds). */
11
+ signinDbMovePollMs?: number;
12
+ }
13
+ /**
14
+ * Waits for a one-shot job app to report healthy, which it does only when its work is verified. The
15
+ * job never exits on success, so any exit is final: `restarting` at once, `exited` once the job was
16
+ * seen running or stayed exited for {@link JOB_EXITED_POLLS} polls. It has failed too at the deadline.
17
+ * A healthy status read before any other could be the container before the stop, so it counts only
18
+ * after a different status was seen.
19
+ */
20
+ export declare function waitForJob(coolify: Pick<PlatformAuthDeps['coolify'], 'getApplication'>, appId: string, runtime: JobRuntime, words: {
21
+ job: string;
22
+ stopped: string;
23
+ }): Promise<void>;
@@ -0,0 +1,41 @@
1
+ /** A failure a job step words itself. Anything else that goes wrong is reported without its cause, which could carry a URL. */
2
+ export class JobFailure extends Error {
3
+ }
4
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
+ /** Polls of an `exited` job with no `running` seen before it counts as a stop (an `exited` read can be the container before the deploy). */
6
+ const JOB_EXITED_POLLS = 6;
7
+ /**
8
+ * Waits for a one-shot job app to report healthy, which it does only when its work is verified. The
9
+ * job never exits on success, so any exit is final: `restarting` at once, `exited` once the job was
10
+ * seen running or stayed exited for {@link JOB_EXITED_POLLS} polls. It has failed too at the deadline.
11
+ * A healthy status read before any other could be the container before the stop, so it counts only
12
+ * after a different status was seen.
13
+ */
14
+ export async function waitForJob(coolify, appId, runtime, words) {
15
+ const now = runtime.now ?? Date.now;
16
+ const sleep = runtime.sleep ?? defaultSleep;
17
+ const timeout = runtime.signinDbMoveTimeoutMs ?? 1_800_000;
18
+ const interval = runtime.signinDbMovePollMs ?? 10_000;
19
+ const deadline = now() + timeout;
20
+ let left = false;
21
+ let running = false;
22
+ let exitedPolls = 0;
23
+ for (;;) {
24
+ const { status } = await coolify.getApplication(appId);
25
+ if (status === 'running:healthy' && left)
26
+ return;
27
+ if (status !== 'running:healthy')
28
+ left = true;
29
+ if (status?.startsWith('running'))
30
+ running = true;
31
+ exitedPolls = status?.startsWith('exited') ? exitedPolls + 1 : 0;
32
+ if (status?.startsWith('restarting') ||
33
+ (status?.startsWith('exited') && (running || exitedPolls >= JOB_EXITED_POLLS))) {
34
+ throw new JobFailure(words.stopped);
35
+ }
36
+ if (now() >= deadline) {
37
+ throw new JobFailure(`${words.job} did not finish within ${Math.round(timeout / 1000)}s (last status: ${status ?? 'unknown'})`);
38
+ }
39
+ await sleep(interval);
40
+ }
41
+ }
@@ -57,6 +57,12 @@ export interface LiveResource {
57
57
  * clients for `FilesReader`/`AiReader` (and every other reader here) is #20,
58
58
  * a non-goal of #27.
59
59
  */
60
+ /**
61
+ * The `zitadel` live resource kind a reader emits, keyed by the app's name,
62
+ * when a recorded app is missing from the listing and a direct read by app id
63
+ * answered not-found (#557). The planner offers the replacement only for it.
64
+ */
65
+ export declare const APP_GONE_KIND = "app-gone";
60
66
  export interface ZitadelReader {
61
67
  listResources(orgId: OrgId): Promise<LiveResource[]>;
62
68
  }
package/dist/plan/live.js CHANGED
@@ -1,6 +1,22 @@
1
1
  import { ownsServices } from '../org.js';
2
2
  import { ownHostnames } from './own-hostnames.js';
3
3
  import { OWN_SERVICES_APPLIED_SCOPE_KEY, orgTarget } from './own-services.js';
4
+ /**
5
+ * One narrow interface per system (#19 "Done when"): a single read, scoped
6
+ * to one org, returning everything foundry can see there. Real
7
+ * implementations follow `bootstrap/coolify-api.ts`'s pattern in
8
+ * `wtfalch/app-template` — a token read from the environment or the secrets
9
+ * file, never the command line, never logged. Nothing here calls anything
10
+ * real; `plan-apply.test.ts` and `diff.test.ts` use fakes. Wiring real
11
+ * clients for `FilesReader`/`AiReader` (and every other reader here) is #20,
12
+ * a non-goal of #27.
13
+ */
14
+ /**
15
+ * The `zitadel` live resource kind a reader emits, keyed by the app's name,
16
+ * when a recorded app is missing from the listing and a direct read by app id
17
+ * answered not-found (#557). The planner offers the replacement only for it.
18
+ */
19
+ export const APP_GONE_KIND = 'app-gone';
4
20
  /** Every live resource foundry can see for `orgId`, across all six systems. */
5
21
  export async function readLiveResources(orgId, readers, org, orgRecordedIds) {
6
22
  const [zitadel, coolify, cloudflare, files, ai, cms] = await Promise.all([
@@ -104,6 +104,11 @@ export interface LoginHostBinding {
104
104
  apps?: Record<string, 'agora' | 'boule' | 'archon'>;
105
105
  /** In-app sign-in: the login serves this host under `/sign-in`. Absent: the root. */
106
106
  basePath?: typeof SIGN_IN_BASE_PATH;
107
+ /**
108
+ * The domain this host's company-wide cookies use: a bare lower-case name that the host equals or sits
109
+ * under. Absent: host-only cookies. An older login ignores the field.
110
+ */
111
+ cookieDomain?: string;
107
112
  }
108
113
  /**
109
114
  * The app hosts that sign in on their own address when the company has `signIn.inApp`: Agora's and
@@ -138,6 +143,14 @@ export interface LoginHostBindingsPort {
138
143
  * The login-host step calls it after all its upserts and before any app is repointed.
139
144
  */
140
145
  flush?(): Promise<void>;
146
+ /**
147
+ * Adds or replaces `clientId -> organisation id` entries in the document's `clientOrganizations`, the
148
+ * map the shared login host checks a customers client's organisation scope against. Merged by key.
149
+ * Optional: a port without it cannot write the map, and the plan says so in one fixed line.
150
+ */
151
+ upsertClientOrganizations?(entries: Readonly<Record<string, string>>): Promise<void>;
152
+ /** Removes those client ids from the map. An id that is absent is done. */
153
+ deleteClientOrganizations?(clientIds: readonly string[]): Promise<void>;
141
154
  }
142
155
  /**
143
156
  * Scope id holding what the binding was last written with
@@ -160,6 +173,21 @@ export declare function customersEntryKeys(org: DeclaredOrg): Set<string>;
160
173
  * app hosts that have their own binding (in-app sign-in) are not in the company's.
161
174
  */
162
175
  export declare function loginHostClientIds(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, except?: readonly string[]): string[];
176
+ /**
177
+ * Scope id holding the map last written to `clientOrganizations`: sorted `clientId=organisationId` pairs
178
+ * joined by `,` ({@link clientOrganizationsState}). Absent or empty means none.
179
+ */
180
+ export declare const LOGIN_CLIENT_ORGS_SCOPE_KEY = "login_client_orgs";
181
+ /**
182
+ * The customers clients of the org and the customers organisation each signs in to, for the shared login
183
+ * host to check: every customers entry whose client id and the org's `zitadel:customers:<org.id>` are both
184
+ * recorded. Staff clients are not here.
185
+ */
186
+ export declare function customersClientOrganizations(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): Record<string, string>;
187
+ /** {@link LOGIN_CLIENT_ORGS_SCOPE_KEY}'s value for a map. */
188
+ export declare function clientOrganizationsState(map: Readonly<Record<string, string>>): string;
189
+ /** The client ids a recorded {@link LOGIN_CLIENT_ORGS_SCOPE_KEY} value holds. */
190
+ export declare function clientOrganizationIds(state: string | undefined): string[];
163
191
  /** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
164
192
  export declare function loginBindingState(host: string, clientIds: readonly string[]): string;
165
193
  /**
@@ -189,6 +189,42 @@ export function loginHostClientIds(org, recorded, except = []) {
189
189
  ].filter((id) => Boolean(id));
190
190
  return [...new Set(ids)].filter((id) => !except.includes(id)).sort();
191
191
  }
192
+ /**
193
+ * Scope id holding the map last written to `clientOrganizations`: sorted `clientId=organisationId` pairs
194
+ * joined by `,` ({@link clientOrganizationsState}). Absent or empty means none.
195
+ */
196
+ export const LOGIN_CLIENT_ORGS_SCOPE_KEY = 'login_client_orgs';
197
+ /**
198
+ * The customers clients of the org and the customers organisation each signs in to, for the shared login
199
+ * host to check: every customers entry whose client id and the org's `zitadel:customers:<org.id>` are both
200
+ * recorded. Staff clients are not here.
201
+ */
202
+ export function customersClientOrganizations(org, recorded) {
203
+ const customersOrgId = recorded[`zitadel:customers:${org.id}`];
204
+ if (!customersOrgId)
205
+ return {};
206
+ const map = {};
207
+ for (const key of customersEntryKeys(org)) {
208
+ const clientId = recorded[ENTRY_CLIENT_ID_SCOPE_PREFIX + key];
209
+ if (clientId)
210
+ map[clientId] = customersOrgId;
211
+ }
212
+ return map;
213
+ }
214
+ /** {@link LOGIN_CLIENT_ORGS_SCOPE_KEY}'s value for a map. */
215
+ export function clientOrganizationsState(map) {
216
+ return Object.entries(map)
217
+ .map(([clientId, orgId]) => `${clientId}=${orgId}`)
218
+ .sort()
219
+ .join(',');
220
+ }
221
+ /** The client ids a recorded {@link LOGIN_CLIENT_ORGS_SCOPE_KEY} value holds. */
222
+ export function clientOrganizationIds(state) {
223
+ return (state ?? '')
224
+ .split(',')
225
+ .map((pair) => pair.split('=')[0] ?? '')
226
+ .filter((id) => id.length > 0);
227
+ }
192
228
  /** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
193
229
  export function loginBindingState(host, clientIds) {
194
230
  return `${host} ${clientIds.join(',')}`;
@@ -11,7 +11,15 @@ import { appNameFor } from './platform-diff.js';
11
11
  */
12
12
  export function companyServiceNames(org) {
13
13
  const s = org.slug;
14
- const names = [`${s}-agora`, `${s}-boule`, `${s}-boule-db`, `${s}-tunnel`, `${s}-cloudflared`];
14
+ const names = [
15
+ `${s}-agora`,
16
+ `${s}-boule`,
17
+ `${s}-boule-db`,
18
+ `${s}-postgres`,
19
+ `${s}-postgres-job`,
20
+ `${s}-tunnel`,
21
+ `${s}-cloudflared`,
22
+ ];
15
23
  if (org.chat?.enabled) {
16
24
  names.push(...Object.values(chatInternalNames(s)), ...Object.values(chatRekeyedInternalNames(s)));
17
25
  }
@@ -0,0 +1,22 @@
1
+ import type { OrgServiceDatabase } from '../org.js';
2
+ /** Env of the job app: the server's admin URL, and one password per home that is not recorded yet. */
3
+ export declare const ORG_POSTGRES_JOB_ADMIN_ENV = "ADMIN_DATABASE_URL";
4
+ /** Env name of a home's owner password: `PW_<HOME>`. */
5
+ export declare function orgPostgresPasswordEnv(schema: string): string;
6
+ /** Env name of the runtime role's password, set for desktop-auth only (its migrate has no role step). */
7
+ export declare function orgPostgresRuntimePasswordEnv(schema: string): string;
8
+ /** Only the home that has no app of its own to create its runtime role gets one from the job. */
9
+ export declare function jobCreatesRuntimeRole(home: OrgServiceDatabase): boolean;
10
+ /**
11
+ * The job's script (D-F). CONNECT stays with PUBLIC, the Postgres default: the template apps create
12
+ * their own runtime role on first boot and none of them grants it CONNECT, so a revoke would lock
13
+ * out the service (found by the T-PROVISION test against Boule's own boot script). What keeps a
14
+ * home closed is the schema: `public` is revoked from PUBLIC and owned by the home's owner.
15
+ * Idempotent per home: a role or database that exists is kept, and an owner
16
+ * password is set only for a home the caller passes, which is a home not yet recorded. Roles are made
17
+ * by `format('%I')` and passwords by `format('%L')`, so no value is spliced into SQL by the shell.
18
+ * It never prints a URL or a password: psql's output is discarded and an error line names the home.
19
+ */
20
+ export declare function orgPostgresProvisionScript(homes: readonly OrgServiceDatabase[]): string[];
21
+ /** The job: the same pinned image as the legacy databases, so `psql` matches the server's major version. */
22
+ export declare function orgPostgresProvisionDockerfile(homes: readonly OrgServiceDatabase[]): string;
@@ -0,0 +1,91 @@
1
+ import { pinnedRef } from '../bootstrap/versions.js';
2
+ /** Env of the job app: the server's admin URL, and one password per home that is not recorded yet. */
3
+ export const ORG_POSTGRES_JOB_ADMIN_ENV = 'ADMIN_DATABASE_URL';
4
+ const JOB_DONE_FILE = '/tmp/done';
5
+ /** Left by a run that did not finish: a restart of the same container then stops at once instead of running again. */
6
+ const JOB_FAILED_FILE = '/tmp/failed';
7
+ const NAME = /^[a-z][a-z0-9_]{0,62}$/;
8
+ /** Env name of a home's owner password: `PW_<HOME>`. */
9
+ export function orgPostgresPasswordEnv(schema) {
10
+ return `PW_${schema.toUpperCase()}`;
11
+ }
12
+ /** Env name of the runtime role's password, set for desktop-auth only (its migrate has no role step). */
13
+ export function orgPostgresRuntimePasswordEnv(schema) {
14
+ return `${orgPostgresPasswordEnv(schema)}_RT`;
15
+ }
16
+ /** Only the home that has no app of its own to create its runtime role gets one from the job. */
17
+ export function jobCreatesRuntimeRole(home) {
18
+ return home.schema === 'desktop_auth';
19
+ }
20
+ function checked(name) {
21
+ if (!NAME.test(name))
22
+ throw new Error(`Postgres name is not valid: ${name}`);
23
+ return name;
24
+ }
25
+ function homeBlock(home) {
26
+ const owner = checked(home.ownerRole);
27
+ const db = checked(home.database);
28
+ const pw = orgPostgresPasswordEnv(home.schema);
29
+ const rtCreated = jobCreatesRuntimeRole(home);
30
+ const rtPw = orgPostgresRuntimePasswordEnv(home.schema);
31
+ const attributes = home.split ? 'LOGIN CREATEROLE' : 'LOGIN';
32
+ const collation = home.collateC ? " LC_COLLATE ''C'' LC_CTYPE ''C''" : '';
33
+ const lines = [
34
+ `[ -n "\${${pw}:-}" ] || fail "${home.schema}"`,
35
+ ...(rtCreated ? [`[ -n "\${${rtPw}:-}" ] || fail "${home.schema}"`] : []),
36
+ `psql "$${ORG_POSTGRES_JOB_ADMIN_ENV}" -X -q -v ON_ERROR_STOP=1 > /dev/null 2>&1 <<'SQL' || fail "${home.schema}"`,
37
+ `\\set owner '${owner}'`,
38
+ `\\set db '${db}'`,
39
+ `\\getenv pw ${pw}`,
40
+ `SELECT format('CREATE ROLE %I ${attributes}', :'owner') WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'owner') \\gexec`,
41
+ `SELECT format('ALTER ROLE %I PASSWORD %L', :'owner', :'pw') \\gexec`,
42
+ `SELECT format('CREATE DATABASE %I OWNER %I TEMPLATE template0 ENCODING ''UTF8''${collation}', :'db', :'owner') WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = :'db') \\gexec`,
43
+ ];
44
+ if (rtCreated) {
45
+ const rt = checked(home.runtimeRole);
46
+ lines.push(`\\set rt '${rt}'`, `\\getenv pwrt ${rtPw}`, "SELECT format('CREATE ROLE %I LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOREPLICATION', :'rt') WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'rt') \\gexec", "SELECT format('ALTER ROLE %I PASSWORD %L', :'rt', :'pwrt') \\gexec", 'GRANT CONNECT ON DATABASE :"db" TO :"rt";');
47
+ }
48
+ lines.push('\\connect :"db"', 'REVOKE ALL ON SCHEMA public FROM PUBLIC;', 'ALTER SCHEMA public OWNER TO :"owner";');
49
+ if (rtCreated) {
50
+ lines.push('GRANT USAGE ON SCHEMA public TO :"rt";', 'ALTER DEFAULT PRIVILEGES FOR ROLE :"owner" IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO :"rt";', 'ALTER DEFAULT PRIVILEGES FOR ROLE :"owner" IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO :"rt";');
51
+ }
52
+ lines.push('SQL', `echo "org-postgres: ${home.schema} ready"`);
53
+ return lines;
54
+ }
55
+ /**
56
+ * The job's script (D-F). CONNECT stays with PUBLIC, the Postgres default: the template apps create
57
+ * their own runtime role on first boot and none of them grants it CONNECT, so a revoke would lock
58
+ * out the service (found by the T-PROVISION test against Boule's own boot script). What keeps a
59
+ * home closed is the schema: `public` is revoked from PUBLIC and owned by the home's owner.
60
+ * Idempotent per home: a role or database that exists is kept, and an owner
61
+ * password is set only for a home the caller passes, which is a home not yet recorded. Roles are made
62
+ * by `format('%I')` and passwords by `format('%L')`, so no value is spliced into SQL by the shell.
63
+ * It never prints a URL or a password: psql's output is discarded and an error line names the home.
64
+ */
65
+ export function orgPostgresProvisionScript(homes) {
66
+ if (homes.length === 0)
67
+ throw new Error('org-postgres job: no homes');
68
+ return [
69
+ '#!/bin/sh',
70
+ 'set -eu',
71
+ `trap '[ -f ${JOB_DONE_FILE} ] || touch ${JOB_FAILED_FILE}' EXIT`,
72
+ "trap 'exit 1' HUP INT TERM",
73
+ `[ ! -f ${JOB_FAILED_FILE} ] || exit 1`,
74
+ `rm -f ${JOB_DONE_FILE}`,
75
+ 'fail() { echo "org-postgres: $1 failed"; exit 1; }',
76
+ ...homes.flatMap(homeBlock),
77
+ `echo "org-postgres: ${homes.length} homes ready"`,
78
+ `touch ${JOB_DONE_FILE}`,
79
+ 'exec sleep infinity',
80
+ ];
81
+ }
82
+ /** The job: the same pinned image as the legacy databases, so `psql` matches the server's major version. */
83
+ export function orgPostgresProvisionDockerfile(homes) {
84
+ const encoded = Buffer.from(`${orgPostgresProvisionScript(homes).join('\n')}\n`, 'utf8').toString('base64');
85
+ return [
86
+ `FROM postgres:${pinnedRef('postgres')}`,
87
+ `RUN echo '${encoded}' | base64 -d > /usr/local/bin/org-postgres && chmod 0755 /usr/local/bin/org-postgres`,
88
+ 'ENTRYPOINT ["/usr/local/bin/org-postgres"]',
89
+ `HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=1 CMD ["test", "-f", "${JOB_DONE_FILE}"]`,
90
+ ].join('\n');
91
+ }
@@ -0,0 +1,115 @@
1
+ import { type DeclaredOrg, type OrgServiceSchema } from '../org.js';
2
+ import type { UnmanagedItem } from '../plan.js';
3
+ import type { DraftStep } from './diff.js';
4
+ import type { CoolifyPort } from './executor.js';
5
+ import { type JobRuntime } from './job-app.js';
6
+ import type { StepScope } from './own-services.js';
7
+ /** Set only by `confirmOrgPostgresMigration`. Without it no migration step is planned, applied or run. */
8
+ export declare const ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY = "org_postgres_migration_confirmed";
9
+ /** The copy job's app id while it exists. */
10
+ export declare const ORG_POSTGRES_MIGRATE_APP_SCOPE_KEY = "org_postgres_migrate_app";
11
+ export interface OrgPostgresMigrationConfirmation {
12
+ /** Id of the staging copy the operator ran this on first, for example `staging-2026-10-20`. 3-80 characters. */
13
+ stagingCopy: string;
14
+ /** Who confirmed. */
15
+ confirmedBy: string;
16
+ /** ISO time. */
17
+ confirmedAt: string;
18
+ }
19
+ /** Thrown before anything is called when the confirmation is not recorded. */
20
+ export declare class OrgPostgresMigrationNotConfirmedError extends Error {
21
+ constructor();
22
+ }
23
+ /** Thrown by `confirmOrgPostgresMigration` for a confirmation that is not valid; nothing is recorded. */
24
+ export declare class InvalidMigrationConfirmationError extends Error {
25
+ readonly reason: string;
26
+ constructor(reason: string);
27
+ }
28
+ /** Why a confirmation is not valid, or null. Pure. */
29
+ export declare function invalidMigrationConfirmation(c: OrgPostgresMigrationConfirmation): string | null;
30
+ /** Pure. The recorded confirmation, or null when absent or not valid. */
31
+ export declare function parseMigrationConfirmation(raw: string | undefined): OrgPostgresMigrationConfirmation | null;
32
+ /** The home a planned `org-postgres-migrate` step names, from its action key `<org.id>:<home>`. */
33
+ export declare function migrateHomeOf(action: {
34
+ key: string;
35
+ desired: Record<string, unknown>;
36
+ }): string;
37
+ export interface OrgPostgresMigrationPlan {
38
+ steps: DraftStep[];
39
+ unmanaged: UnmanagedItem[];
40
+ }
41
+ /**
42
+ * Pure. The migration step for the first home that has a legacy database and is ready, and only once the
43
+ * confirmation is recorded. `legacy` is the homes {@link planOrgPostgres} reports as unmigrated. A home is
44
+ * ready when the server and its database are recorded: provisioning goes first. Unconfirmed: no step, one
45
+ * line. Migrated homes whose old database is still recorded get a line, because the old database is never
46
+ * deleted here.
47
+ */
48
+ export declare function planOrgPostgresMigration(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, legacy: readonly OrgServiceSchema[]): OrgPostgresMigrationPlan;
49
+ export declare const ORG_POSTGRES_MIGRATE_ENV: {
50
+ readonly oldUrl: "OLD_DATABASE_URL";
51
+ readonly newUrl: "NEW_DATABASE_URL";
52
+ readonly newAdminUrl: "NEW_ADMIN_DATABASE_URL";
53
+ };
54
+ /** The job app's name in Coolify. */
55
+ export declare const orgPostgresMigrateJobName: (org: Pick<DeclaredOrg, "slug">) => string;
56
+ /**
57
+ * The job's script. It reads the old database and writes the new one; the old database is never
58
+ * written. A new database that holds tables is a stop, never a drop. It prints counts and fixed
59
+ * messages, never a URL.
60
+ */
61
+ export declare function orgPostgresMigrateScript(ownerRole: string): string[];
62
+ /** The job: the same pinned image as the servers, so `pg_dump` matches their major version. */
63
+ export declare function orgPostgresMigrateDockerfile(ownerRole: string): string;
64
+ type MigrateCoolify = Pick<CoolifyPort, 'findOrCreateEnvironment' | 'findApplicationByName' | 'getApplication' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy' | 'raw'>;
65
+ /** The apps that use a home's database. */
66
+ export interface MigrationConsumers {
67
+ appIds: readonly string[];
68
+ /** A template app takes `DATABASE_URL_OWNER` and `DATABASE_URL`; a matrix stack renders its config from the server on the next apply. */
69
+ template: boolean;
70
+ }
71
+ export interface OrgPostgresMigrateRuntime extends JobRuntime {
72
+ /** How long a consumer may take to report stopped (default 2 minutes). */
73
+ signinDbStopTimeoutMs?: number;
74
+ /** How long a consumer may take to report healthy on the new database (default 5 minutes). */
75
+ readyTimeoutMs?: number;
76
+ }
77
+ /** Everything the step takes from the executor. New ones go here, never as positional arguments. */
78
+ export interface OrgPostgresMigrateDeps {
79
+ coolify: MigrateCoolify;
80
+ ensureProject(): Promise<string>;
81
+ serverUuid(): Promise<string>;
82
+ /** Starts the company's Postgres server when it is not running and returns its live record. */
83
+ startServer(uuid: string): Promise<{
84
+ internal_db_url?: string;
85
+ status?: string;
86
+ }>;
87
+ /** Reads an old Coolify database as it is. Never starts, writes or deletes it. */
88
+ readLegacyDatabase(uuid: string): Promise<{
89
+ internal_db_url?: string;
90
+ status?: string;
91
+ }>;
92
+ /** The password the host holds under a ref, or undefined. */
93
+ resolveSecret(ref: string): Promise<string | undefined>;
94
+ consumers(home: OrgServiceSchema): Promise<MigrationConsumers>;
95
+ onSecret?(value: string): void;
96
+ runtime?: OrgPostgresMigrateRuntime;
97
+ }
98
+ /**
99
+ * Copies one home's data from its legacy Coolify database to the company's Postgres server (#444).
100
+ *
101
+ * 1. The recorded confirmation is checked before anything is called.
102
+ * 2. The consumer apps are stopped, and the step waits until Coolify reports them stopped.
103
+ * 3. A one-shot job app dumps the old database into the new, empty one and compares the tables, the
104
+ * row count of each and the value of each sequence in both.
105
+ * 4. A template app gets the new `DATABASE_URL_OWNER` and `DATABASE_URL` (the runtime password it has now).
106
+ * 5. `org_postgres_migrated.<home>` is recorded and persisted BEFORE a consumer starts on the new database.
107
+ *
108
+ * The old database is never written to or deleted. A failure before step 4 starts the consumers on the
109
+ * old database again. A failure around step 4 reads the live env back and acts on what is really set;
110
+ * when it cannot tell, the consumers stay stopped and the error says so. A rerun that finds the new
111
+ * URL already set records and starts without copying. A matrix stack is left stopped: its config is
112
+ * rendered from the server by the next apply, which starts it.
113
+ */
114
+ export declare function migrateOrgPostgresHome(scope: StepScope, deps: OrgPostgresMigrateDeps, org: DeclaredOrg, homeKey: string): Promise<void>;
115
+ export {};