@wtfalch/foundry 0.68.0 → 0.68.2
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.
- package/dist/org.d.ts +10 -0
- package/dist/org.js +14 -0
- package/dist/plan/apps-db-route.d.ts +20 -14
- package/dist/plan/apps-db-route.js +36 -26
- package/dist/plan/apps-db.d.ts +76 -0
- package/dist/plan/apps-db.js +132 -0
- package/dist/plan/diff.js +28 -3
- package/dist/plan/executor.d.ts +2 -0
- package/dist/plan/executor.js +42 -15
- package/dist/plan/org-apps-admin.d.ts +36 -12
- package/dist/plan/org-apps-admin.js +115 -52
- package/dist/plan/plan-apply.js +1 -0
- package/dist/plan/signin-db-backup.d.ts +5 -1
- package/dist/plan/signin-db-backup.js +7 -4
- package/dist/plan/signin-db.d.ts +2 -0
- package/dist/plan/signin-db.js +1 -0
- package/dist/plan/worker-app.d.ts +6 -0
- package/dist/plan/worker-app.js +7 -1
- package/dist/store/postgres.js +5 -2
- package/package.json +1 -1
package/dist/org.d.ts
CHANGED
|
@@ -502,6 +502,16 @@ export interface BouleApp extends OrgBuiltinApp {
|
|
|
502
502
|
}
|
|
503
503
|
/** True while the company's Boule runs apps (`org.boule.apps`). */
|
|
504
504
|
export declare const appsOn: (org: Pick<DeclaredOrg, "boule">) => boolean;
|
|
505
|
+
/**
|
|
506
|
+
* `boule.apps` for a company this is the first time anyone declares (#1041): on, when the caller leaves
|
|
507
|
+
* `boule` unset entirely. Candor FX's go-live hung on "Getting your company ready for apps" because the
|
|
508
|
+
* first install waits on a request to turn this on, and that request did not apply
|
|
509
|
+
* (https://github.com/wtfalch/candorfx/issues/32) -- a brand-new company skips the wait by starting with
|
|
510
|
+
* apps already on. Only `DeclaredOrgStore.create` calls this, never `update`: an existing company's stored
|
|
511
|
+
* value (on or off) is read back as it is and is never touched here, so this never overrides a choice
|
|
512
|
+
* anyone -- a caller or an operator -- already made, explicit `boule.apps: false` included.
|
|
513
|
+
*/
|
|
514
|
+
export declare function withNewOrgAppsDefault(org: DeclaredOrg): DeclaredOrg;
|
|
505
515
|
/**
|
|
506
516
|
* The company's kosmos: its own creative workspace (canvas, libraries, photos, pages) that drives the
|
|
507
517
|
* frontend of its apps. Unlike Boule and Agora, which every company runs, kosmos is set up only when the
|
package/dist/org.js
CHANGED
|
@@ -37,6 +37,20 @@ export function normalizeTesters(testers) {
|
|
|
37
37
|
}
|
|
38
38
|
/** True while the company's Boule runs apps (`org.boule.apps`). */
|
|
39
39
|
export const appsOn = (org) => org.boule?.apps === true;
|
|
40
|
+
/**
|
|
41
|
+
* `boule.apps` for a company this is the first time anyone declares (#1041): on, when the caller leaves
|
|
42
|
+
* `boule` unset entirely. Candor FX's go-live hung on "Getting your company ready for apps" because the
|
|
43
|
+
* first install waits on a request to turn this on, and that request did not apply
|
|
44
|
+
* (https://github.com/wtfalch/candorfx/issues/32) -- a brand-new company skips the wait by starting with
|
|
45
|
+
* apps already on. Only `DeclaredOrgStore.create` calls this, never `update`: an existing company's stored
|
|
46
|
+
* value (on or off) is read back as it is and is never touched here, so this never overrides a choice
|
|
47
|
+
* anyone -- a caller or an operator -- already made, explicit `boule.apps: false` included.
|
|
48
|
+
*/
|
|
49
|
+
export function withNewOrgAppsDefault(org) {
|
|
50
|
+
if (org.boule?.apps !== undefined)
|
|
51
|
+
return org;
|
|
52
|
+
return { ...org, boule: { ...org.boule, apps: true } };
|
|
53
|
+
}
|
|
40
54
|
/** True while the company's kosmos is turned on (`org.kosmos.enabled`). */
|
|
41
55
|
export const kosmosOn = (org) => org.kosmos?.enabled === true;
|
|
42
56
|
/** The most volumes one entry may declare. */
|
|
@@ -2,15 +2,17 @@ import { type DeclaredOrg } from '../org.js';
|
|
|
2
2
|
import type { DraftStep } from './diff.js';
|
|
3
3
|
import type { StepExecutionContext } from './plan-apply.js';
|
|
4
4
|
import { type WorkerAppDatabaseDeps } from './worker-app-database.js';
|
|
5
|
+
import { APPS_DB_ENTRY_KEY } from './worker-app.js';
|
|
5
6
|
/**
|
|
6
|
-
* F4 (#925): the Hyperdrive route to the company's
|
|
7
|
-
* apps admin role lives on). Boule creates a database per app copy on
|
|
8
|
-
* the edge it calls makes one Hyperdrive config per database.
|
|
9
|
-
* Docker network only through the company's Cloudflare
|
|
10
|
-
* makes the route once for the server, not once per
|
|
7
|
+
* F4 (#925): the Hyperdrive route to the company's apps database (`<slug>-apps-db`, foundry's own TLS
|
|
8
|
+
* Postgres, `apps-db.ts`; the one the apps admin role lives on). Boule creates a database per app copy on
|
|
9
|
+
* that server (`app_<app>_<copy>`) and the edge it calls makes one Hyperdrive config per database.
|
|
10
|
+
* Hyperdrive reaches a database inside Coolify's Docker network only through the company's Cloudflare
|
|
11
|
+
* tunnel behind an Access service token, so this step makes the route once for the server, not once per
|
|
12
|
+
* database:
|
|
11
13
|
*
|
|
12
14
|
* Access app on `<8 hex of org id>-apps-db-hd.<zone>` (a service-token policy) -> tunnel rule
|
|
13
|
-
* `tcp://<
|
|
15
|
+
* `tcp://<slug>-apps-db:5432` -> proxied CNAME to the tunnel,
|
|
14
16
|
*
|
|
15
17
|
* the same three parts `worker-app-database.ts` makes for a Worker app's own database (`ensureDatabaseRoute`
|
|
16
18
|
* is shared), under the scope-id family `bgdb.apps-db.*`, so org destroy and the existing teardown find them.
|
|
@@ -19,20 +21,28 @@ import { type WorkerAppDatabaseDeps } from './worker-app-database.js';
|
|
|
19
21
|
*
|
|
20
22
|
* Boule's env gets: {@link APPS_DATABASE_HOST_ENV} (the tunnel hostname Hyperdrive connects to),
|
|
21
23
|
* {@link APPS_DATABASE_ACCESS_CLIENT_ID_ENV} and {@link APPS_DATABASE_ACCESS_CLIENT_SECRET_ENV} (the Access
|
|
22
|
-
* service token), {@link APPS_DATABASE_PORT_ENV}.
|
|
23
|
-
*
|
|
24
|
+
* service token), {@link APPS_DATABASE_PORT_ENV}.
|
|
25
|
+
*
|
|
26
|
+
* Hyperdrive refuses an origin that does not speak TLS (Cloudflare error 2012), tunnel or not, and a Coolify
|
|
27
|
+
* database cannot turn TLS on. That is why the route goes to foundry's own TLS server and not to the
|
|
28
|
+
* company's Coolify Postgres server `<slug>-postgres`. The server accepts TLS connections only, so
|
|
29
|
+
* Hyperdrive's default `sslmode=require` works and no CA is given. A company whose route was recorded
|
|
30
|
+
* against `<slug>-postgres` is moved by the next plan: the applied marker names the hostname and the
|
|
31
|
+
* server, the tunnel rule is written again, and nothing on the old server is touched.
|
|
24
32
|
*/
|
|
25
|
-
export
|
|
33
|
+
export { APPS_DB_ENTRY_KEY };
|
|
26
34
|
export declare const APPS_DATABASE_HOST_ENV = "APPS_DATABASE_HOST";
|
|
27
35
|
export declare const APPS_DATABASE_PORT_ENV = "APPS_DATABASE_PORT";
|
|
28
36
|
export declare const APPS_DATABASE_ACCESS_CLIENT_ID_ENV = "APPS_DATABASE_ACCESS_CLIENT_ID";
|
|
29
37
|
export declare const APPS_DATABASE_ACCESS_CLIENT_SECRET_ENV = "APPS_DATABASE_ACCESS_CLIENT_SECRET";
|
|
30
38
|
export declare const APPS_DATABASE_ADMIN_URL_ENV = "APPS_DATABASE_ADMIN_URL";
|
|
31
39
|
export declare const APPS_DATABASE_ROUTE_ENVS: readonly ["APPS_DATABASE_HOST", "APPS_DATABASE_PORT", "APPS_DATABASE_ACCESS_CLIENT_ID", "APPS_DATABASE_ACCESS_CLIENT_SECRET"];
|
|
32
|
-
/** The
|
|
40
|
+
/** The route last written to Boule ({@link appsDbRouteMarker}), recorded last so a failed run is repeated. */
|
|
33
41
|
export declare const APPS_DB_ROUTE_APPLIED_SCOPE_KEY = "apps_db_route_applied";
|
|
34
42
|
/** The tunnel hostname of the company's apps database route. */
|
|
35
43
|
export declare const appsDbHostname: (org: DeclaredOrg) => string;
|
|
44
|
+
/** The applied marker: the tunnel hostname and the server it reaches. A route recorded with the hostname alone (the old Postgres server) differs, so it is planned again. */
|
|
45
|
+
export declare const appsDbRouteMarker: (org: DeclaredOrg) => string;
|
|
36
46
|
export interface PlanAppsDbRouteOptions {
|
|
37
47
|
/** Access, tunnel, DNS and zone ports are available (edge). */
|
|
38
48
|
accessReady: boolean;
|
|
@@ -70,8 +80,6 @@ export interface AppsDbRouteDeps {
|
|
|
70
80
|
};
|
|
71
81
|
/** Registers a value for redaction before it is written anywhere. */
|
|
72
82
|
secret(value: string): void;
|
|
73
|
-
/** The Postgres server's host inside the Docker network (its internal URL's host). */
|
|
74
|
-
upstreamHost(serverId: string): Promise<string>;
|
|
75
83
|
boule: AppsDbRouteBoule;
|
|
76
84
|
/** Test hook: the timeout of a call, in ms. */
|
|
77
85
|
timeoutMs?: number;
|
|
@@ -86,5 +94,3 @@ export declare function applyAppsDbRoute(deps: AppsDbRouteDeps, org: DeclaredOrg
|
|
|
86
94
|
export declare function retireAppsDbRoute(deps: AppsDbRouteDeps, org: DeclaredOrg, context: StepExecutionContext): Promise<void>;
|
|
87
95
|
/** The held secret ref of the route's Access token (for tests and the executor). */
|
|
88
96
|
export declare const appsDbAccessRef: (org: Pick<DeclaredOrg, "id">) => string;
|
|
89
|
-
/** True when the apps admin role is recorded: the route is worth making only with it (used by the planner's caller). */
|
|
90
|
-
export declare const appsAdminRecorded: (recorded: Readonly<Record<string, string>>) => boolean;
|
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { appsOn } from '../org.js';
|
|
2
2
|
import { withCallTimeout } from '../worker-app-upload.js';
|
|
3
|
-
import {
|
|
3
|
+
import { APPS_DB_SCOPE_KEYS, appsDbName, appsDbRecorded } from './apps-db.js';
|
|
4
4
|
import { REMOTE_DATABASE_PORT, remoteDatabaseKeys } from './remote-database.js';
|
|
5
5
|
import { deleteWorkerAppDatabaseRoute, ensureDatabaseRoute, ensureServiceToken, hyperdriveHostname, } from './worker-app-database.js';
|
|
6
|
-
import { WORKER_APP_CALL_TIMEOUT_MS, workerAppApex, workerAppSecretRef } from './worker-app.js';
|
|
6
|
+
import { APPS_DB_ENTRY_KEY, WORKER_APP_CALL_TIMEOUT_MS, workerAppApex, workerAppSecretRef, } from './worker-app.js';
|
|
7
7
|
/**
|
|
8
|
-
* F4 (#925): the Hyperdrive route to the company's
|
|
9
|
-
* apps admin role lives on). Boule creates a database per app copy on
|
|
10
|
-
* the edge it calls makes one Hyperdrive config per database.
|
|
11
|
-
* Docker network only through the company's Cloudflare
|
|
12
|
-
* makes the route once for the server, not once per
|
|
8
|
+
* F4 (#925): the Hyperdrive route to the company's apps database (`<slug>-apps-db`, foundry's own TLS
|
|
9
|
+
* Postgres, `apps-db.ts`; the one the apps admin role lives on). Boule creates a database per app copy on
|
|
10
|
+
* that server (`app_<app>_<copy>`) and the edge it calls makes one Hyperdrive config per database.
|
|
11
|
+
* Hyperdrive reaches a database inside Coolify's Docker network only through the company's Cloudflare
|
|
12
|
+
* tunnel behind an Access service token, so this step makes the route once for the server, not once per
|
|
13
|
+
* database:
|
|
13
14
|
*
|
|
14
15
|
* Access app on `<8 hex of org id>-apps-db-hd.<zone>` (a service-token policy) -> tunnel rule
|
|
15
|
-
* `tcp://<
|
|
16
|
+
* `tcp://<slug>-apps-db:5432` -> proxied CNAME to the tunnel,
|
|
16
17
|
*
|
|
17
18
|
* the same three parts `worker-app-database.ts` makes for a Worker app's own database (`ensureDatabaseRoute`
|
|
18
19
|
* is shared), under the scope-id family `bgdb.apps-db.*`, so org destroy and the existing teardown find them.
|
|
@@ -21,10 +22,16 @@ import { WORKER_APP_CALL_TIMEOUT_MS, workerAppApex, workerAppSecretRef } from '.
|
|
|
21
22
|
*
|
|
22
23
|
* Boule's env gets: {@link APPS_DATABASE_HOST_ENV} (the tunnel hostname Hyperdrive connects to),
|
|
23
24
|
* {@link APPS_DATABASE_ACCESS_CLIENT_ID_ENV} and {@link APPS_DATABASE_ACCESS_CLIENT_SECRET_ENV} (the Access
|
|
24
|
-
* service token), {@link APPS_DATABASE_PORT_ENV}.
|
|
25
|
-
*
|
|
25
|
+
* service token), {@link APPS_DATABASE_PORT_ENV}.
|
|
26
|
+
*
|
|
27
|
+
* Hyperdrive refuses an origin that does not speak TLS (Cloudflare error 2012), tunnel or not, and a Coolify
|
|
28
|
+
* database cannot turn TLS on. That is why the route goes to foundry's own TLS server and not to the
|
|
29
|
+
* company's Coolify Postgres server `<slug>-postgres`. The server accepts TLS connections only, so
|
|
30
|
+
* Hyperdrive's default `sslmode=require` works and no CA is given. A company whose route was recorded
|
|
31
|
+
* against `<slug>-postgres` is moved by the next plan: the applied marker names the hostname and the
|
|
32
|
+
* server, the tunnel rule is written again, and nothing on the old server is touched.
|
|
26
33
|
*/
|
|
27
|
-
export
|
|
34
|
+
export { APPS_DB_ENTRY_KEY };
|
|
28
35
|
export const APPS_DATABASE_HOST_ENV = 'APPS_DATABASE_HOST';
|
|
29
36
|
export const APPS_DATABASE_PORT_ENV = 'APPS_DATABASE_PORT';
|
|
30
37
|
export const APPS_DATABASE_ACCESS_CLIENT_ID_ENV = 'APPS_DATABASE_ACCESS_CLIENT_ID';
|
|
@@ -36,10 +43,12 @@ export const APPS_DATABASE_ROUTE_ENVS = [
|
|
|
36
43
|
APPS_DATABASE_ACCESS_CLIENT_ID_ENV,
|
|
37
44
|
APPS_DATABASE_ACCESS_CLIENT_SECRET_ENV,
|
|
38
45
|
];
|
|
39
|
-
/** The
|
|
46
|
+
/** The route last written to Boule ({@link appsDbRouteMarker}), recorded last so a failed run is repeated. */
|
|
40
47
|
export const APPS_DB_ROUTE_APPLIED_SCOPE_KEY = 'apps_db_route_applied';
|
|
41
48
|
/** The tunnel hostname of the company's apps database route. */
|
|
42
49
|
export const appsDbHostname = (org) => hyperdriveHostname(org, APPS_DB_ENTRY_KEY, workerAppApex(org));
|
|
50
|
+
/** The applied marker: the tunnel hostname and the server it reaches. A route recorded with the hostname alone (the old Postgres server) differs, so it is planned again. */
|
|
51
|
+
export const appsDbRouteMarker = (org) => `${appsDbHostname(org)}|${appsDbName(org)}`;
|
|
43
52
|
const routeKeys = remoteDatabaseKeys(APPS_DB_ENTRY_KEY);
|
|
44
53
|
/**
|
|
45
54
|
* Pure. `apply` once the company's apps are on and everything the route needs is recorded or planned;
|
|
@@ -77,8 +86,9 @@ export function planAppsDbRoute(org, recordedIds, plannedSteps, options) {
|
|
|
77
86
|
}
|
|
78
87
|
const planned = (system, kind) => plannedSteps.some((p) => p.action.system === system && p.action.kind === kind && p.action.key === org.id);
|
|
79
88
|
const hostname = appsDbHostname(org);
|
|
80
|
-
if (recordedIds[APPS_DB_ROUTE_APPLIED_SCOPE_KEY] ===
|
|
89
|
+
if (recordedIds[APPS_DB_ROUTE_APPLIED_SCOPE_KEY] === appsDbRouteMarker(org)) {
|
|
81
90
|
return { steps: [], blocked };
|
|
91
|
+
}
|
|
82
92
|
if (!options.accessReady)
|
|
83
93
|
return block('the host supplies no Access, tunnel, DNS or zone port');
|
|
84
94
|
if (!options.canHoldSecrets)
|
|
@@ -87,8 +97,8 @@ export function planAppsDbRoute(org, recordedIds, plannedSteps, options) {
|
|
|
87
97
|
if (lost) {
|
|
88
98
|
return block(`service token ${lost} was created but its secret was never saved, so it cannot be used. To start over, turn apps off and apply (that removes the route and clears this note), delete the token ${lost} in Cloudflare Zero Trust if it is still listed, then turn apps on again`);
|
|
89
99
|
}
|
|
90
|
-
if (!recordedIds
|
|
91
|
-
return block('its
|
|
100
|
+
if (!appsDbRecorded(recordedIds) && !planned('coolify', 'apps-db')) {
|
|
101
|
+
return block('its apps database is not provisioned yet');
|
|
92
102
|
}
|
|
93
103
|
if (!options.tunnelReady)
|
|
94
104
|
return block("the company's Cloudflare tunnel is not applied yet");
|
|
@@ -97,7 +107,9 @@ export function planAppsDbRoute(org, recordedIds, plannedSteps, options) {
|
|
|
97
107
|
}
|
|
98
108
|
return {
|
|
99
109
|
steps: [
|
|
100
|
-
draft({ mode: 'apply', hostname },
|
|
110
|
+
draft({ mode: 'apply', hostname }, recordedRoute
|
|
111
|
+
? `point ${org.slug}'s apps database route (${hostname}) at ${appsDbName(org)} and give Boule the host and its Access token`
|
|
112
|
+
: `make ${org.slug}'s apps database route (${hostname}) and give Boule the host and its Access token`),
|
|
101
113
|
],
|
|
102
114
|
blocked,
|
|
103
115
|
};
|
|
@@ -113,17 +125,17 @@ export async function applyAppsDbRoute(deps, org, context) {
|
|
|
113
125
|
const recorded = context.recordedIds();
|
|
114
126
|
if (!appsOn(org))
|
|
115
127
|
throw new Error(`${at}: the company's Boule runs no apps (org.boule.apps)`);
|
|
116
|
-
const serverId = recorded[
|
|
117
|
-
if (!serverId)
|
|
118
|
-
throw new Error(`${at}: ${org.slug}'s
|
|
128
|
+
const serverId = recorded[APPS_DB_SCOPE_KEYS.app];
|
|
129
|
+
if (!serverId || !recorded[APPS_DB_SCOPE_KEYS.tls]) {
|
|
130
|
+
throw new Error(`${at}: ${org.slug}'s apps database is not recorded yet`);
|
|
131
|
+
}
|
|
119
132
|
const tunnelId = recorded[`cloudflare:tunnel:${org.id}`];
|
|
120
133
|
if (!tunnelId)
|
|
121
134
|
throw new Error(`${at}: ${org.slug}'s Cloudflare tunnel is not applied yet`);
|
|
122
135
|
if (!recorded[`coolify:boule:${org.id}`])
|
|
123
136
|
throw new Error(`${at}: Boule is not recorded yet`);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
throw new Error(`${at}: the Postgres server reports no host`);
|
|
137
|
+
// The server's host inside the Docker network is its app's own name (`custom_internal_name`).
|
|
138
|
+
const upstreamHost = appsDbName(org);
|
|
127
139
|
const hostname = appsDbHostname(org);
|
|
128
140
|
const access = await ensureServiceToken({ access: deps.database.access, heldKeys: deps.heldKeys, secret: deps.secret }, org, { key: APPS_DB_ENTRY_KEY }, `${org.slug}-apps`, at, call, context, put);
|
|
129
141
|
await ensureDatabaseRoute(deps.database, call, context, {
|
|
@@ -145,7 +157,7 @@ export async function applyAppsDbRoute(deps, org, context) {
|
|
|
145
157
|
{ key: APPS_DATABASE_ACCESS_CLIENT_SECRET_ENV, value: access.clientSecret, literal: true },
|
|
146
158
|
]));
|
|
147
159
|
await call('restart Boule', () => deps.boule.restart());
|
|
148
|
-
context.recordScopeId(APPS_DB_ROUTE_APPLIED_SCOPE_KEY,
|
|
160
|
+
context.recordScopeId(APPS_DB_ROUTE_APPLIED_SCOPE_KEY, appsDbRouteMarker(org));
|
|
149
161
|
await context.persistScopeIds();
|
|
150
162
|
}
|
|
151
163
|
/**
|
|
@@ -164,5 +176,3 @@ export async function retireAppsDbRoute(deps, org, context) {
|
|
|
164
176
|
}
|
|
165
177
|
/** The held secret ref of the route's Access token (for tests and the executor). */
|
|
166
178
|
export const appsDbAccessRef = (org) => workerAppSecretRef(org.id, APPS_DB_ENTRY_KEY, 'HYPERDRIVE_ACCESS');
|
|
167
|
-
/** True when the apps admin role is recorded: the route is worth making only with it (used by the planner's caller). */
|
|
168
|
-
export const appsAdminRecorded = (recorded) => Boolean(recorded[ORG_APPS_ADMIN_SCOPE_KEY]);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
import { type ProjectSource, type StepScope } from './own-services.js';
|
|
4
|
+
import type { PlatformAuthDeps } from './platform-providers.js';
|
|
5
|
+
import { type SigninDbKeys } from './signin-db-tls.js';
|
|
6
|
+
import { type SigninDbRuntime } from './signin-db.js';
|
|
7
|
+
/**
|
|
8
|
+
* The company's apps database: the Postgres server Boule creates a database per app copy on
|
|
9
|
+
* (`app_<app>_<copy>`) and Cloudflare Hyperdrive reaches through the company's tunnel. Hyperdrive
|
|
10
|
+
* refuses an origin that does not speak TLS, tunnel or not (error 2012), and Coolify 4.4.2 cannot turn
|
|
11
|
+
* on a database's TLS, so this server is foundry's own TLS Postgres, the same image and machinery as
|
|
12
|
+
* the Worker app database (`worker-app-db.ts`, `signin-db.ts`): `<slug>-apps-db`, planned while
|
|
13
|
+
* `appsOn(org)`. It is a server of its own, not `<slug>-app-db`, because that one holds a Worker app's
|
|
14
|
+
* data under one owner and backs up one database; this one holds whatever Boule creates and its
|
|
15
|
+
* nightly dump takes the whole server.
|
|
16
|
+
*
|
|
17
|
+
* The server's superuser `apps_owner` is for foundry's own steps (the apps admin role job); Boule gets
|
|
18
|
+
* the narrow `<slug>_apps_admin` role only. Every network connection must be TLS with a password
|
|
19
|
+
* (`hostssl` only), so `sslmode=require` is enough for a client, with no CA file to hand out. The org's
|
|
20
|
+
* CA is held under {@link appsDbCaRef}; the server certificate is renewed by planning the step again
|
|
21
|
+
* when it is due. Apps off plans nothing for it and deletes nothing: the server and its data stay.
|
|
22
|
+
*/
|
|
23
|
+
/** Recorded ids: the app, "the step finished", and the server certificate's expiry. */
|
|
24
|
+
export declare const APPS_DB_SCOPE_KEYS: import("./signin-db-tls.js").TlsDbScopeKeys;
|
|
25
|
+
/** `unmanaged` key of the one blocked line {@link planAppsDb} reports. */
|
|
26
|
+
export declare const APPS_DB_BLOCKED_KEY = "apps-db";
|
|
27
|
+
export declare const APPS_DB_USER = "apps_owner";
|
|
28
|
+
export declare const APPS_DB_DATABASE = "postgres";
|
|
29
|
+
export declare const APPS_DB_BACKUP_PREFIX = "apps-db";
|
|
30
|
+
export declare const appsDbCaRef: (orgId: string) => string;
|
|
31
|
+
export declare const appsDbServerRef: (orgId: string) => string;
|
|
32
|
+
/** The server's name in Coolify and its host inside the Docker network. */
|
|
33
|
+
export declare const appsDbName: (org: Pick<DeclaredOrg, "slug">) => string;
|
|
34
|
+
/** The server is made: its app and the "finished" marker are recorded. */
|
|
35
|
+
export declare const appsDbRecorded: (recordedIds: Readonly<Record<string, string>>) => boolean;
|
|
36
|
+
export interface PlanAppsDbOptions {
|
|
37
|
+
org: DeclaredOrg;
|
|
38
|
+
recordedIds: Readonly<Record<string, string>>;
|
|
39
|
+
now: number;
|
|
40
|
+
/** The held keys can store the CA and certificate. Default true. */
|
|
41
|
+
keysPuttable?: boolean;
|
|
42
|
+
/** The host supplies the backup bucket the server dumps into. Default true. */
|
|
43
|
+
backupConfigured?: boolean;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* One `coolify:apps-db` step per org whose Boule runs apps: planned until the app and the "finished"
|
|
47
|
+
* marker are recorded, and again when the server certificate is due. A step that cannot succeed yet
|
|
48
|
+
* (no held-key put, no backup bucket) is a blocked line, not a step. With apps off nothing is planned.
|
|
49
|
+
*/
|
|
50
|
+
export declare function planAppsDb(options: PlanAppsDbOptions): {
|
|
51
|
+
step: DraftStep | null;
|
|
52
|
+
blocked: string | null;
|
|
53
|
+
};
|
|
54
|
+
export interface ProvisionAppsDbDeps {
|
|
55
|
+
coolify: PlatformAuthDeps['coolify'];
|
|
56
|
+
heldKeys: SigninDbKeys | undefined;
|
|
57
|
+
signinDbBackup: PlatformAuthDeps['signinDbBackup'];
|
|
58
|
+
runtime?: SigninDbRuntime;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Brings the org's apps database up (`provisionSigninDb` with the apps database's names) and records the
|
|
62
|
+
* "finished" marker last, so a run that died halfway is planned again. Returns the app id. A failure
|
|
63
|
+
* halfway leaves the CA and certificate held (never made twice), the app, its volume and its recorded
|
|
64
|
+
* id in place; nothing is deleted, and the next apply continues from there.
|
|
65
|
+
*/
|
|
66
|
+
export declare function provisionAppsDb(scope: StepScope, deps: ProvisionAppsDbDeps, org: DeclaredOrg, input: {
|
|
67
|
+
project: ProjectSource;
|
|
68
|
+
label: string;
|
|
69
|
+
}): Promise<{
|
|
70
|
+
appId: string;
|
|
71
|
+
}>;
|
|
72
|
+
/**
|
|
73
|
+
* The superuser's address for foundry's own steps (the apps admin role job): the server's internal host
|
|
74
|
+
* and the password in its env. It asks for TLS, which the server demands. A secret.
|
|
75
|
+
*/
|
|
76
|
+
export declare function appsDbAdminUrl(coolify: Pick<PlatformAuthDeps['coolify'], 'listEnvs'>, org: DeclaredOrg, appId: string): Promise<string>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { appsOn } from '../org.js';
|
|
2
|
+
import { orgTarget } from './own-services.js';
|
|
3
|
+
import { signinDbCertDue, tlsDbScopeKeys } from './signin-db-tls.js';
|
|
4
|
+
import { SIGNIN_DB_ENV, SIGNIN_DB_PORT, dockerfileAppCreator, provisionSigninDb, withCallTimeout, } from './signin-db.js';
|
|
5
|
+
/**
|
|
6
|
+
* The company's apps database: the Postgres server Boule creates a database per app copy on
|
|
7
|
+
* (`app_<app>_<copy>`) and Cloudflare Hyperdrive reaches through the company's tunnel. Hyperdrive
|
|
8
|
+
* refuses an origin that does not speak TLS, tunnel or not (error 2012), and Coolify 4.4.2 cannot turn
|
|
9
|
+
* on a database's TLS, so this server is foundry's own TLS Postgres, the same image and machinery as
|
|
10
|
+
* the Worker app database (`worker-app-db.ts`, `signin-db.ts`): `<slug>-apps-db`, planned while
|
|
11
|
+
* `appsOn(org)`. It is a server of its own, not `<slug>-app-db`, because that one holds a Worker app's
|
|
12
|
+
* data under one owner and backs up one database; this one holds whatever Boule creates and its
|
|
13
|
+
* nightly dump takes the whole server.
|
|
14
|
+
*
|
|
15
|
+
* The server's superuser `apps_owner` is for foundry's own steps (the apps admin role job); Boule gets
|
|
16
|
+
* the narrow `<slug>_apps_admin` role only. Every network connection must be TLS with a password
|
|
17
|
+
* (`hostssl` only), so `sslmode=require` is enough for a client, with no CA file to hand out. The org's
|
|
18
|
+
* CA is held under {@link appsDbCaRef}; the server certificate is renewed by planning the step again
|
|
19
|
+
* when it is due. Apps off plans nothing for it and deletes nothing: the server and its data stay.
|
|
20
|
+
*/
|
|
21
|
+
/** Recorded ids: the app, "the step finished", and the server certificate's expiry. */
|
|
22
|
+
export const APPS_DB_SCOPE_KEYS = tlsDbScopeKeys('apps_db');
|
|
23
|
+
/** `unmanaged` key of the one blocked line {@link planAppsDb} reports. */
|
|
24
|
+
export const APPS_DB_BLOCKED_KEY = 'apps-db';
|
|
25
|
+
export const APPS_DB_USER = 'apps_owner';
|
|
26
|
+
export const APPS_DB_DATABASE = 'postgres';
|
|
27
|
+
export const APPS_DB_BACKUP_PREFIX = 'apps-db';
|
|
28
|
+
export const appsDbCaRef = (orgId) => `apps-db:${orgId}:ca`;
|
|
29
|
+
export const appsDbServerRef = (orgId) => `apps-db:${orgId}:server`;
|
|
30
|
+
/** The server's name in Coolify and its host inside the Docker network. */
|
|
31
|
+
export const appsDbName = (org) => `${org.slug}-apps-db`;
|
|
32
|
+
/** The server is made: its app and the "finished" marker are recorded. */
|
|
33
|
+
export const appsDbRecorded = (recordedIds) => Boolean(recordedIds[APPS_DB_SCOPE_KEYS.app] && recordedIds[APPS_DB_SCOPE_KEYS.tls]);
|
|
34
|
+
/**
|
|
35
|
+
* One `coolify:apps-db` step per org whose Boule runs apps: planned until the app and the "finished"
|
|
36
|
+
* marker are recorded, and again when the server certificate is due. A step that cannot succeed yet
|
|
37
|
+
* (no held-key put, no backup bucket) is a blocked line, not a step. With apps off nothing is planned.
|
|
38
|
+
*/
|
|
39
|
+
export function planAppsDb(options) {
|
|
40
|
+
const { org, recordedIds } = options;
|
|
41
|
+
if (!appsOn(org))
|
|
42
|
+
return { step: null, blocked: null };
|
|
43
|
+
const done = appsDbRecorded(recordedIds);
|
|
44
|
+
if (done && !signinDbCertDue(recordedIds, options.now, APPS_DB_SCOPE_KEYS)) {
|
|
45
|
+
return { step: null, blocked: null };
|
|
46
|
+
}
|
|
47
|
+
if (options.keysPuttable === false) {
|
|
48
|
+
return {
|
|
49
|
+
step: null,
|
|
50
|
+
blocked: `${org.slug}'s apps database is blocked, not planned: the host cannot store the database certificate (held keys have no put)`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (options.backupConfigured === false) {
|
|
54
|
+
return {
|
|
55
|
+
step: null,
|
|
56
|
+
blocked: `${org.slug}'s apps database is blocked, not planned: the host supplies no backup bucket for the database`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
blocked: null,
|
|
61
|
+
step: {
|
|
62
|
+
action: { system: 'coolify', kind: 'apps-db', key: org.id, desired: {} },
|
|
63
|
+
step: {
|
|
64
|
+
description: done
|
|
65
|
+
? `renew the server certificate of ${org.slug}'s apps database`
|
|
66
|
+
: `provision ${org.slug}'s apps database (${appsDbName(org)}) as a TLS Postgres`,
|
|
67
|
+
destroys: false,
|
|
68
|
+
migrates: false,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Brings the org's apps database up (`provisionSigninDb` with the apps database's names) and records the
|
|
75
|
+
* "finished" marker last, so a run that died halfway is planned again. Returns the app id. A failure
|
|
76
|
+
* halfway leaves the CA and certificate held (never made twice), the app, its volume and its recorded
|
|
77
|
+
* id in place; nothing is deleted, and the next apply continues from there.
|
|
78
|
+
*/
|
|
79
|
+
export async function provisionAppsDb(scope, deps, org, input) {
|
|
80
|
+
const coolify = withCallTimeout(deps.coolify);
|
|
81
|
+
const name = appsDbName(org);
|
|
82
|
+
const keys = APPS_DB_SCOPE_KEYS;
|
|
83
|
+
const record = async (key, id) => {
|
|
84
|
+
scope.recordScopeId(key, id);
|
|
85
|
+
await scope.persist();
|
|
86
|
+
};
|
|
87
|
+
const projectUuid = await input.project.ensure(coolify, scope);
|
|
88
|
+
const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
89
|
+
const db = await provisionSigninDb({
|
|
90
|
+
coolify,
|
|
91
|
+
keys: deps.heldKeys,
|
|
92
|
+
runtime: deps.runtime ?? {},
|
|
93
|
+
name,
|
|
94
|
+
internalHost: name,
|
|
95
|
+
volumeName: `${name}-data`,
|
|
96
|
+
serverCertRef: appsDbServerRef(org.id),
|
|
97
|
+
slug: org.slug,
|
|
98
|
+
backup: deps.signinDbBackup,
|
|
99
|
+
backupBucket: orgTarget(org).names.signinBackupBucket,
|
|
100
|
+
recorded: scope.recordedIds(),
|
|
101
|
+
label: input.label,
|
|
102
|
+
environment,
|
|
103
|
+
record,
|
|
104
|
+
create: dockerfileAppCreator(coolify, {
|
|
105
|
+
projectUuid,
|
|
106
|
+
environment,
|
|
107
|
+
name,
|
|
108
|
+
description: `${org.slug}'s apps database with TLS`,
|
|
109
|
+
}),
|
|
110
|
+
}, {
|
|
111
|
+
appName: name,
|
|
112
|
+
user: APPS_DB_USER,
|
|
113
|
+
database: APPS_DB_DATABASE,
|
|
114
|
+
caRef: appsDbCaRef(org.id),
|
|
115
|
+
caCommonName: `foundry ${org.slug} apps database CA`,
|
|
116
|
+
scopeKeys: keys,
|
|
117
|
+
backupPrefix: APPS_DB_BACKUP_PREFIX,
|
|
118
|
+
dumpAll: true,
|
|
119
|
+
});
|
|
120
|
+
await record(keys.tls, db.appId);
|
|
121
|
+
return { appId: db.appId };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The superuser's address for foundry's own steps (the apps admin role job): the server's internal host
|
|
125
|
+
* and the password in its env. It asks for TLS, which the server demands. A secret.
|
|
126
|
+
*/
|
|
127
|
+
export async function appsDbAdminUrl(coolify, org, appId) {
|
|
128
|
+
const password = (await coolify.listEnvs(appId)).find((e) => e.key === SIGNIN_DB_ENV.password && !e.is_preview && e.value)?.value;
|
|
129
|
+
if (!password)
|
|
130
|
+
throw new Error(`${org.slug}'s apps database holds no password yet`);
|
|
131
|
+
return `postgres://${APPS_DB_USER}:${encodeURIComponent(password)}@${appsDbName(org)}:${SIGNIN_DB_PORT}/${APPS_DB_DATABASE}?sslmode=require`;
|
|
132
|
+
}
|
package/dist/plan/diff.js
CHANGED
|
@@ -6,6 +6,7 @@ import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPEC
|
|
|
6
6
|
import { planAnalyticsBoule } from './analytics-boule.js';
|
|
7
7
|
import { analyticsEnvDrift, analyticsWanted, desiredAnalyticsEnv } from './analytics.js';
|
|
8
8
|
import { planAppsDbRoute } from './apps-db-route.js';
|
|
9
|
+
import { APPS_DB_BLOCKED_KEY, planAppsDb } from './apps-db.js';
|
|
9
10
|
import { planAppsZones } from './apps-zones.js';
|
|
10
11
|
import { bgKey, planBlueGreen, } from './blue-green.js';
|
|
11
12
|
import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_MAIL_TOKEN_ENV, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_ROOM_SCOPE_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, customersOrgIdOf, customersRoomValue, desiredBouleEnv, planBouleOwners, staleCustomersEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
@@ -920,10 +921,20 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
|
|
|
920
921
|
plannedSteps.push(desktopCallbacks);
|
|
921
922
|
// #444, last: a Postgres step that fails must not stop the zone, mail, app, Agora and Boule steps of a live company.
|
|
922
923
|
plannedSteps.push(...orgPostgresSteps);
|
|
923
|
-
// F4 (#925): with apps on, the apps
|
|
924
|
+
// F4 (#925): with apps on, the company's apps database (a TLS Postgres: Hyperdrive refuses one without),
|
|
925
|
+
// then the apps admin role on it and the route Hyperdrive reaches it by.
|
|
926
|
+
const appsDb = planAppsDb({
|
|
927
|
+
org,
|
|
928
|
+
recordedIds,
|
|
929
|
+
now: (options.now ?? Date.now)(),
|
|
930
|
+
keysPuttable: options.workerAppDbKeysPuttable ?? true,
|
|
931
|
+
backupConfigured: options.workerAppDbBackupConfigured ?? true,
|
|
932
|
+
});
|
|
933
|
+
if (appsDb.step)
|
|
934
|
+
plannedSteps.push(appsDb.step);
|
|
924
935
|
const appsAdmin = planAppsAdmin(org, recordedIds, plannedSteps, {
|
|
925
936
|
canHoldSecrets: platformReadiness.canHoldSecrets === true,
|
|
926
|
-
|
|
937
|
+
appsDbPlanned: Boolean(appsDb.step),
|
|
927
938
|
});
|
|
928
939
|
plannedSteps.push(...appsAdmin.steps);
|
|
929
940
|
const appsDbRoute = planAppsDbRoute(org, recordedIds, plannedSteps, {
|
|
@@ -1020,6 +1031,9 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
|
|
|
1020
1031
|
unmanaged.push(...entryAiKeys.blocked);
|
|
1021
1032
|
unmanaged.push(...edgeWorkers.blocked);
|
|
1022
1033
|
unmanaged.push(...workerApps.blocked);
|
|
1034
|
+
if (appsDb.blocked) {
|
|
1035
|
+
unmanaged.push({ description: appsDb.blocked, key: APPS_DB_BLOCKED_KEY });
|
|
1036
|
+
}
|
|
1023
1037
|
if (workerAppDb.blocked) {
|
|
1024
1038
|
unmanaged.push({ description: workerAppDb.blocked, key: WORKER_APP_DB_BLOCKED_KEY });
|
|
1025
1039
|
}
|
|
@@ -4208,6 +4222,17 @@ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = n
|
|
|
4208
4222
|
* `HostingEntry.database`, #71) -- `validateHosting` requires
|
|
4209
4223
|
* `hosting.enabled` before this can be true. Same park-not-delete shape as
|
|
4210
4224
|
* {@link planApp}.
|
|
4225
|
+
*
|
|
4226
|
+
* #1038: the default `isOn` (`live.config.enabled !== false`) never sees a
|
|
4227
|
+
* park step's work, because providers' own database config carries no
|
|
4228
|
+
* `enabled` field -- only a Coolify `status` (`ProvidersCoolifyReader`
|
|
4229
|
+
* passes `database.status` straight into `config.status`). A stopped
|
|
4230
|
+
* database therefore always read as on, so a company with no entry left
|
|
4231
|
+
* using a database re-planned the park step forever and Archon's
|
|
4232
|
+
* company-app apply, which refuses every install while a step that
|
|
4233
|
+
* destroys data is pending, could never clear it. `isOn` here reads
|
|
4234
|
+
* `config.status` instead, the same `exited`/`stopped` convention
|
|
4235
|
+
* {@link planKosmos} already uses for its own park step.
|
|
4211
4236
|
*/
|
|
4212
4237
|
function planDatabase(org, byKey, claimed, recordedIds) {
|
|
4213
4238
|
// #444: only a company whose app database is still its own Coolify database
|
|
@@ -4225,7 +4250,7 @@ function planDatabase(org, byKey, claimed, recordedIds) {
|
|
|
4225
4250
|
if (!appHome || !legacy || !recordedIds[legacy] || serviceOnServer(org, appHome, recordedIds)) {
|
|
4226
4251
|
return null;
|
|
4227
4252
|
}
|
|
4228
|
-
return planHostingToggle('coolify', 'database', org.hosting.entries.some(entryHasDatabase), org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)
|
|
4253
|
+
return planHostingToggle('coolify', 'database', org.hosting.entries.some(entryHasDatabase), org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`, org.id, (live) => !/^(exited|stopped)/.test(String(live.config.status ?? '')));
|
|
4229
4254
|
}
|
|
4230
4255
|
function sortedNames(names) {
|
|
4231
4256
|
return names.filter(Boolean).sort().join(',');
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -3641,6 +3641,8 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
3641
3641
|
private ownAuthDeps;
|
|
3642
3642
|
/** `plan/worker-app-db.ts`: the org's Worker app database as a TLS Postgres, and its server certificate's renewal. */
|
|
3643
3643
|
private workerAppDb;
|
|
3644
|
+
/** `plan/apps-db.ts`: the company's apps database as a TLS Postgres, and its server certificate's renewal. */
|
|
3645
|
+
private appsDb;
|
|
3644
3646
|
/** `plan/signin-db-move.ts`: copies a company's sign-in data from its Coolify database to the TLS database. */
|
|
3645
3647
|
private ownAuthDbMove;
|
|
3646
3648
|
private ownAuth;
|
package/dist/plan/executor.js
CHANGED
|
@@ -12,6 +12,7 @@ import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID
|
|
|
12
12
|
import { ANALYTICS_BOULE_APPLIED_SCOPE_KEY, ANALYTICS_BOULE_ENV_NAMES, analyticsBouleMarker, } from './analytics-boule.js';
|
|
13
13
|
import { ANALYTICS_DATABASE_SCOPE_KEY, ANALYTICS_DEPLOYED_SCOPE_KEY, ANALYTICS_OPERATOR_KEY_ENV, analyticsEnvDrift, analyticsHostnameFor, analyticsUrlFor, desiredAnalyticsEnv, } from './analytics.js';
|
|
14
14
|
import { APPS_DATABASE_ADMIN_URL_ENV, applyAppsDbRoute, retireAppsDbRoute, } from './apps-db-route.js';
|
|
15
|
+
import { appsDbAdminUrl, provisionAppsDb } from './apps-db.js';
|
|
15
16
|
import { APPS_ZONES_APPLIED_SCOPE_KEY, applyAppsZones, retireAppsZones } from './apps-zones.js';
|
|
16
17
|
import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, applyWorkspaceServer, } from './blue-green-apply.js';
|
|
17
18
|
import { actionsKeyName, bgKey, blueGreenSubjects, colorAppName, healthTokenName, liveAppNameFor, mintActionsKey, mintPinKey, parseGuards, pinKeyName, splitServers, } from './blue-green.js';
|
|
@@ -1230,9 +1231,12 @@ export class FoundryStepExecutor {
|
|
|
1230
1231
|
}
|
|
1231
1232
|
async execute(action, context) {
|
|
1232
1233
|
const { org } = context;
|
|
1233
|
-
// The Worker app
|
|
1234
|
+
// The Worker app and apps databases use only platform ports, and their backup needs the files host's
|
|
1234
1235
|
// bucket and key ports, which the scoped copy drops with `ownServices` (foundry#878).
|
|
1235
|
-
if (ownsServices(org) &&
|
|
1236
|
+
if (ownsServices(org) &&
|
|
1237
|
+
!action.kind.startsWith('own-') &&
|
|
1238
|
+
action.kind !== 'worker-app-db' &&
|
|
1239
|
+
action.kind !== 'apps-db') {
|
|
1236
1240
|
return this.executeScoped(action, context);
|
|
1237
1241
|
}
|
|
1238
1242
|
return this.run(action, context);
|
|
@@ -1433,6 +1437,8 @@ export class FoundryStepExecutor {
|
|
|
1433
1437
|
return this.ownAuthDbMove(org, context);
|
|
1434
1438
|
case 'coolify:worker-app-db':
|
|
1435
1439
|
return this.workerAppDb(org, context);
|
|
1440
|
+
case 'coolify:apps-db':
|
|
1441
|
+
return this.appsDb(org, context);
|
|
1436
1442
|
case 'coolify:own-jobs':
|
|
1437
1443
|
return this.ownJobs(action, org, context);
|
|
1438
1444
|
case 'coolify:own-jobs-route':
|
|
@@ -4519,9 +4525,17 @@ export class FoundryStepExecutor {
|
|
|
4519
4525
|
const base = this.orgPostgresDeps(org, context);
|
|
4520
4526
|
const boule = this.appsBoule(org, context);
|
|
4521
4527
|
const { deliverSecret: _held, ...shared } = base;
|
|
4528
|
+
const withAppsDb = {
|
|
4529
|
+
...shared,
|
|
4530
|
+
appsDbAdminUrl: async (appId) => {
|
|
4531
|
+
const url = await appsDbAdminUrl(this.ports.coolify, org, appId);
|
|
4532
|
+
this.handledSecrets.add(url);
|
|
4533
|
+
return url;
|
|
4534
|
+
},
|
|
4535
|
+
};
|
|
4522
4536
|
if (action.desired.mode === 'retire') {
|
|
4523
4537
|
await retireOrgAppsAdmin(scopeFromOrg(context, {}), {
|
|
4524
|
-
...
|
|
4538
|
+
...withAppsDb,
|
|
4525
4539
|
clearHeldUrl: async (ref) => {
|
|
4526
4540
|
// The deliver sink has no delete; the held-key store blanks a value (an empty one reads as not held).
|
|
4527
4541
|
if (typeof this.ports.heldKeys?.put !== 'function') {
|
|
@@ -4543,7 +4557,7 @@ export class FoundryStepExecutor {
|
|
|
4543
4557
|
if (!boule)
|
|
4544
4558
|
throw new Error(`${at}: Boule is not recorded yet`);
|
|
4545
4559
|
const deps = {
|
|
4546
|
-
...
|
|
4560
|
+
...withAppsDb,
|
|
4547
4561
|
deliverSecret: async (ref, url) => {
|
|
4548
4562
|
await this.ports.deliverSecret(ref, url);
|
|
4549
4563
|
await boule.upsertEnvs([{ key: APPS_DATABASE_ADMIN_URL_ENV, value: url, literal: true }]);
|
|
@@ -4576,13 +4590,6 @@ export class FoundryStepExecutor {
|
|
|
4576
4590
|
},
|
|
4577
4591
|
heldKeys,
|
|
4578
4592
|
secret: (value) => void this.handledSecrets.add(value),
|
|
4579
|
-
upstreamHost: async (serverId) => {
|
|
4580
|
-
const server = await getDatabaseStarted(this.ports.coolify, serverId);
|
|
4581
|
-
if (!server.internal_db_url || !URL.canParse(server.internal_db_url))
|
|
4582
|
-
return '';
|
|
4583
|
-
this.handledSecrets.add(server.internal_db_url);
|
|
4584
|
-
return new URL(server.internal_db_url).hostname;
|
|
4585
|
-
},
|
|
4586
4593
|
boule: boule ?? {
|
|
4587
4594
|
upsertEnvs: async () => undefined,
|
|
4588
4595
|
deleteEnvs: async () => undefined,
|
|
@@ -9052,6 +9059,26 @@ export class FoundryStepExecutor {
|
|
|
9052
9059
|
});
|
|
9053
9060
|
return {};
|
|
9054
9061
|
}
|
|
9062
|
+
/** `plan/apps-db.ts`: the company's apps database as a TLS Postgres, and its server certificate's renewal. */
|
|
9063
|
+
async appsDb(org, context) {
|
|
9064
|
+
const files = this.ports.ownServices?.provision.files;
|
|
9065
|
+
await provisionAppsDb(scopeFromOrg(context, {}), {
|
|
9066
|
+
coolify: this.placedForCompany(this.ports.coolify, org, context),
|
|
9067
|
+
heldKeys: this.ports.heldKeys,
|
|
9068
|
+
signinDbBackup: files?.r2Buckets && files.r2Keys
|
|
9069
|
+
? { r2Buckets: files.r2Buckets, r2Keys: files.r2Keys }
|
|
9070
|
+
: undefined,
|
|
9071
|
+
runtime: {
|
|
9072
|
+
...(this.ports.runtime?.sleep ? { sleep: this.ports.runtime.sleep } : {}),
|
|
9073
|
+
...(this.ports.runtime?.now ? { now: this.ports.runtime.now } : {}),
|
|
9074
|
+
onSecret: (value) => void this.handledSecrets.add(value),
|
|
9075
|
+
},
|
|
9076
|
+
}, org, {
|
|
9077
|
+
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
9078
|
+
label: `${org.slug}:apps-db`,
|
|
9079
|
+
});
|
|
9080
|
+
return {};
|
|
9081
|
+
}
|
|
9055
9082
|
/** `plan/signin-db-move.ts`: copies a company's sign-in data from its Coolify database to the TLS database. */
|
|
9056
9083
|
async ownAuthDbMove(org, context) {
|
|
9057
9084
|
const own = this.ports.ownServices;
|
|
@@ -11782,14 +11809,14 @@ export function isNoChangesError(error) {
|
|
|
11782
11809
|
export async function allowPerAppLoginBaseUris(zitadel, instanceBaseUri) {
|
|
11783
11810
|
if (!zitadel.allowPerAppLoginBaseUri)
|
|
11784
11811
|
return;
|
|
11785
|
-
|
|
11786
|
-
if (!listOrgs || !listProjects) {
|
|
11812
|
+
// Called on the port, never pulled off it: a client class reads `this` in each method.
|
|
11813
|
+
if (!zitadel.listOrgs || !zitadel.listProjects) {
|
|
11787
11814
|
throw new Error('the ZITADEL port cannot list every organisation and project, so apps without a login base URI would fall back to the v1 login');
|
|
11788
11815
|
}
|
|
11789
|
-
for (const org of await listOrgs()) {
|
|
11816
|
+
for (const org of await zitadel.listOrgs()) {
|
|
11790
11817
|
if (org.state && org.state !== 'ORG_STATE_ACTIVE')
|
|
11791
11818
|
continue;
|
|
11792
|
-
for (const project of await listProjects(org.id)) {
|
|
11819
|
+
for (const project of await zitadel.listProjects(org.id)) {
|
|
11793
11820
|
if (project.state && project.state !== 'PROJECT_STATE_ACTIVE')
|
|
11794
11821
|
continue;
|
|
11795
11822
|
const listed = await zitadel.listApps(org.id, project.id);
|
|
@@ -7,11 +7,24 @@ import type { StepScope } from './own-services.js';
|
|
|
7
7
|
export declare const appsAdminRoleName: (org: Pick<DeclaredOrg, "slug">) => string;
|
|
8
8
|
/** The job app's name in Coolify. */
|
|
9
9
|
export declare const appsAdminJobName: (org: Pick<DeclaredOrg, "slug">) => string;
|
|
10
|
-
/** Recorded once the role is made, its password set, and the URL delivered. */
|
|
10
|
+
/** Recorded once the role is made, its password set, and the URL delivered: {@link appsAdminDoneValue}, or the old server's name. */
|
|
11
11
|
export declare const ORG_APPS_ADMIN_SCOPE_KEY = "org_apps_admin";
|
|
12
|
+
/**
|
|
13
|
+
* What {@link ORG_APPS_ADMIN_SCOPE_KEY} holds when the role is on the apps database and the job that made it
|
|
14
|
+
* also closed `postgres` and `template1` to PUBLIC. A company recorded with the bare server name (a version
|
|
15
|
+
* before the revokes) is planned once more, which runs the whole idempotent job again.
|
|
16
|
+
*/
|
|
17
|
+
export declare const appsAdminDoneValue: (org: Pick<DeclaredOrg, "slug">) => string;
|
|
18
|
+
/**
|
|
19
|
+
* Set to the server's name when a mint job failed, while the role may have been made there. Not a note that
|
|
20
|
+
* blocks anything: it only makes turning apps off retire the role on the apps database, so a role that
|
|
21
|
+
* {@link ORG_APPS_ADMIN_SCOPE_KEY} never recorded is not left able to log in.
|
|
22
|
+
*/
|
|
23
|
+
export declare const ORG_APPS_ADMIN_TOUCHED_SCOPE_KEY = "org_apps_admin_touched";
|
|
12
24
|
/**
|
|
13
25
|
* An opaque id, set once the password is minted and the job that sets it is about to run, until the URL is
|
|
14
|
-
* delivered. Never the password. A failure before that point leaves no id,
|
|
26
|
+
* delivered. Never the password. A failure before that point leaves no id, and a job that fails clears it (the
|
|
27
|
+
* mint is idempotent and delivered nothing), so the next apply tries again.
|
|
15
28
|
*/
|
|
16
29
|
export declare const ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY = "org_apps_admin_undelivered";
|
|
17
30
|
/** The job app's id while it exists. */
|
|
@@ -34,14 +47,23 @@ export declare function planOrgAppsAdminStep(org: Pick<DeclaredOrg, 'id' | 'slug
|
|
|
34
47
|
step: DraftStep | null;
|
|
35
48
|
blocked: string | null;
|
|
36
49
|
};
|
|
50
|
+
/**
|
|
51
|
+
* The servers a retire has to reach: the apps database when the role is recorded on it (or a mint is
|
|
52
|
+
* pending, which only ever runs against it), and the old `<slug>-postgres` server when the role was made
|
|
53
|
+
* there by an earlier version (or a mint of that version was never delivered, which could be on either).
|
|
54
|
+
*/
|
|
55
|
+
export declare function appsAdminRetireTargets(org: Pick<DeclaredOrg, 'slug'>, recorded: Readonly<Record<string, string>>): {
|
|
56
|
+
legacy: boolean;
|
|
57
|
+
current: boolean;
|
|
58
|
+
};
|
|
37
59
|
export interface PlanAppsAdminOptions {
|
|
38
60
|
canHoldSecrets: boolean;
|
|
39
|
-
/** `coolify:
|
|
40
|
-
|
|
61
|
+
/** `coolify:apps-db` is planned in this same plan, ahead of this step. */
|
|
62
|
+
appsDbPlanned: boolean;
|
|
41
63
|
}
|
|
42
64
|
/**
|
|
43
65
|
* Pure. F4 (#925): the wiring of {@link planOrgAppsAdminStep} to `org.boule.apps`. Apps on: the mint
|
|
44
|
-
* step, once Boule (which receives the URL) and the
|
|
66
|
+
* step, once Boule (which receives the URL) and the apps database are recorded or planned. Apps off with
|
|
45
67
|
* the role recorded (or a mint never delivered): the retire step, which needs no secret and drops nothing.
|
|
46
68
|
*/
|
|
47
69
|
export declare function planAppsAdmin(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], options: PlanAppsAdminOptions): {
|
|
@@ -52,8 +74,8 @@ export declare function planAppsAdmin(org: DeclaredOrg, recordedIds: Readonly<Re
|
|
|
52
74
|
}>;
|
|
53
75
|
};
|
|
54
76
|
/**
|
|
55
|
-
* The job's script: makes the role (idempotent, kept if it already exists), sets its password
|
|
56
|
-
*
|
|
77
|
+
* The job's script: makes the role (idempotent, kept if it already exists), sets its password, closes
|
|
78
|
+
* `postgres` and `template1` to PUBLIC and grants the role CONNECT on `postgres` (see the file comment). Roles are made by
|
|
57
79
|
* `format('%I')` and the password by `format('%L')`, so no value is spliced into SQL by the shell. Setting
|
|
58
80
|
* the password again is harmless, so a mint that is repeated simply replaces it. It never prints a URL or a password: psql's output is discarded and the one
|
|
59
81
|
* error line names neither.
|
|
@@ -67,11 +89,8 @@ export interface OrgAppsAdminDeps {
|
|
|
67
89
|
coolify: AppsAdminCoolify;
|
|
68
90
|
ensureProject(): Promise<string>;
|
|
69
91
|
serverUuid(): Promise<string>;
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
internal_db_url?: string;
|
|
73
|
-
status?: string;
|
|
74
|
-
}>;
|
|
92
|
+
/** The apps database's superuser address (`appsDbAdminUrl`): its internal host, TLS asked for. A secret. */
|
|
93
|
+
appsDbAdminUrl(appId: string): Promise<string>;
|
|
75
94
|
deliverSecret(ref: string, secret: string): Promise<void>;
|
|
76
95
|
/** Registers a value for redaction. */
|
|
77
96
|
onSecret?(value: string): void;
|
|
@@ -92,6 +111,11 @@ export interface OrgAppsAdminDeps {
|
|
|
92
111
|
export declare function provisionOrgAppsAdmin(scope: StepScope, deps: OrgAppsAdminDeps, org: DeclaredOrg): Promise<void>;
|
|
93
112
|
/** What the retire form takes from the executor: the mint form's deps, and the held-key blanking. */
|
|
94
113
|
export interface RetireOrgAppsAdminDeps extends Omit<OrgAppsAdminDeps, 'deliverSecret'> {
|
|
114
|
+
/** Starts the old `<slug>-postgres` server when it is not running and returns its live record (a role made there by an earlier version). */
|
|
115
|
+
startDatabase(uuid: string): Promise<{
|
|
116
|
+
internal_db_url?: string;
|
|
117
|
+
status?: string;
|
|
118
|
+
}>;
|
|
95
119
|
/** Blanks the held URL (the store has no delete: an empty value reads as not held). */
|
|
96
120
|
clearHeldUrl(ref: string): Promise<void>;
|
|
97
121
|
/** Takes `APPS_DATABASE_ADMIN_URL` out of Boule's env. A row already gone is not an error. */
|
|
@@ -1,25 +1,32 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { pinnedRef } from '../bootstrap/versions.js';
|
|
3
3
|
import { ORG_POSTGRES_SERVER_SCOPE_KEY, appsOn } from '../org.js';
|
|
4
|
+
import { APPS_DB_SCOPE_KEYS, appsDbName, appsDbRecorded } from './apps-db.js';
|
|
4
5
|
import { JobFailure, waitForJob } from './job-app.js';
|
|
5
|
-
import { orgPostgresServerName } from './org-postgres.js';
|
|
6
6
|
import { boundedCall, withCallTimeout } from './signin-db.js';
|
|
7
7
|
/**
|
|
8
|
-
* estate#210: a narrow role on the company's
|
|
8
|
+
* estate#210: a narrow role on the company's apps database (`apps-db.ts`, `<slug>-apps-db`, foundry's own
|
|
9
|
+
* TLS Postgres: Hyperdrive refuses an origin without TLS) that Boule uses
|
|
9
10
|
* to create a database per app copy itself -- `LOGIN CREATEDB CREATEROLE` only, never superuser,
|
|
10
11
|
* never able to touch another role's data or database (estate#215, tested on Postgres 16.15). Its
|
|
11
12
|
* connection URL is the one credential Boule is handed; it never sees the server's own admin URL.
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
* is done by Boule
|
|
17
|
-
* revokes CONNECT from PUBLIC and grants it to that
|
|
18
|
-
*
|
|
14
|
+
* CONNECT: the apps database is foundry's own server with no home on it that relies on PUBLIC's CONNECT
|
|
15
|
+
* (unlike `<slug>-postgres`, see `org-postgres-job.ts`), so the mint job revokes CONNECT on `postgres` and
|
|
16
|
+
* `template1` from PUBLIC and grants it to the admin role on `postgres` (the database its URL names). It
|
|
17
|
+
* touches no other database. Isolation between app databases is done by Boule (contract with the Boule
|
|
18
|
+
* builder): on each `app_*` database it creates, Boule revokes CONNECT from PUBLIC and grants it to that
|
|
19
|
+
* database's own role only; until it does, a new database has PUBLIC's default CONNECT, which no setting on
|
|
20
|
+
* this server can change.
|
|
19
21
|
*
|
|
20
22
|
* Wired by F4 (#925): the step is planned only for a company whose Boule runs apps (`appsOn`,
|
|
21
|
-
* `org.boule.apps`), after the company's
|
|
22
|
-
* held at {@link appsAdminUrlRef} and written to Boule's env as `APPS_DATABASE_ADMIN_URL
|
|
23
|
+
* `org.boule.apps`), after the company's apps database and Boule are recorded or planned. The URL is
|
|
24
|
+
* held at {@link appsAdminUrlRef} and written to Boule's env as `APPS_DATABASE_ADMIN_URL`, with
|
|
25
|
+
* `?sslmode=require`: the server accepts TLS connections only and the encryption needs no CA file.
|
|
26
|
+
* The recorded {@link ORG_APPS_ADMIN_SCOPE_KEY} names the server the role is on. A company that made its
|
|
27
|
+
* role on the old `<slug>-postgres` server (an earlier version) has that name recorded, so the step is
|
|
28
|
+
* planned again and makes the role on the apps database; the old role and everything on the old server are
|
|
29
|
+
* left as they are. Turning apps
|
|
23
30
|
* off plans the retire form ({@link retireOrgAppsAdmin}): the env row goes, the held URL is blanked, and
|
|
24
31
|
* the role is set NOLOGIN and its sessions ended. Nothing is dropped: the role, the `app_*` databases
|
|
25
32
|
* and their roles stay, so turning apps on again mints a new password for the same role.
|
|
@@ -34,11 +41,24 @@ function checked(name) {
|
|
|
34
41
|
export const appsAdminRoleName = (org) => `${org.slug}_apps_admin`;
|
|
35
42
|
/** The job app's name in Coolify. */
|
|
36
43
|
export const appsAdminJobName = (org) => `${org.slug}-apps-admin-job`;
|
|
37
|
-
/** Recorded once the role is made, its password set, and the URL delivered. */
|
|
44
|
+
/** Recorded once the role is made, its password set, and the URL delivered: {@link appsAdminDoneValue}, or the old server's name. */
|
|
38
45
|
export const ORG_APPS_ADMIN_SCOPE_KEY = 'org_apps_admin';
|
|
46
|
+
/**
|
|
47
|
+
* What {@link ORG_APPS_ADMIN_SCOPE_KEY} holds when the role is on the apps database and the job that made it
|
|
48
|
+
* also closed `postgres` and `template1` to PUBLIC. A company recorded with the bare server name (a version
|
|
49
|
+
* before the revokes) is planned once more, which runs the whole idempotent job again.
|
|
50
|
+
*/
|
|
51
|
+
export const appsAdminDoneValue = (org) => `${appsDbName(org)}:closed`;
|
|
52
|
+
/**
|
|
53
|
+
* Set to the server's name when a mint job failed, while the role may have been made there. Not a note that
|
|
54
|
+
* blocks anything: it only makes turning apps off retire the role on the apps database, so a role that
|
|
55
|
+
* {@link ORG_APPS_ADMIN_SCOPE_KEY} never recorded is not left able to log in.
|
|
56
|
+
*/
|
|
57
|
+
export const ORG_APPS_ADMIN_TOUCHED_SCOPE_KEY = 'org_apps_admin_touched';
|
|
39
58
|
/**
|
|
40
59
|
* An opaque id, set once the password is minted and the job that sets it is about to run, until the URL is
|
|
41
|
-
* delivered. Never the password. A failure before that point leaves no id,
|
|
60
|
+
* delivered. Never the password. A failure before that point leaves no id, and a job that fails clears it (the
|
|
61
|
+
* mint is idempotent and delivered nothing), so the next apply tries again.
|
|
42
62
|
*/
|
|
43
63
|
export const ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY = 'org_apps_admin_undelivered';
|
|
44
64
|
/** The job app's id while it exists. */
|
|
@@ -54,12 +74,13 @@ export const ORG_APPS_ADMIN_DELIVERY_ATTEMPTS = 3;
|
|
|
54
74
|
* recorded id once the role is confirmed gone from the server.
|
|
55
75
|
*/
|
|
56
76
|
export function planOrgAppsAdminStep(org, recordedIds, options = {}) {
|
|
57
|
-
if (recordedIds[ORG_APPS_ADMIN_SCOPE_KEY])
|
|
77
|
+
if (recordedIds[ORG_APPS_ADMIN_SCOPE_KEY] === appsAdminDoneValue(org)) {
|
|
58
78
|
return { step: null, blocked: null };
|
|
59
|
-
|
|
79
|
+
}
|
|
80
|
+
if (!appsDbRecorded(recordedIds)) {
|
|
60
81
|
return {
|
|
61
82
|
step: null,
|
|
62
|
-
blocked: `${org.slug}'s apps admin role is blocked, not planned: its
|
|
83
|
+
blocked: `${org.slug}'s apps admin role is blocked, not planned: its apps database is not provisioned yet`,
|
|
63
84
|
};
|
|
64
85
|
}
|
|
65
86
|
const lost = recordedIds[ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY];
|
|
@@ -87,9 +108,27 @@ export function planOrgAppsAdminStep(org, recordedIds, options = {}) {
|
|
|
87
108
|
blocked: null,
|
|
88
109
|
};
|
|
89
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* The servers a retire has to reach: the apps database when the role is recorded on it (or a mint is
|
|
113
|
+
* pending, which only ever runs against it), and the old `<slug>-postgres` server when the role was made
|
|
114
|
+
* there by an earlier version (or a mint of that version was never delivered, which could be on either).
|
|
115
|
+
*/
|
|
116
|
+
export function appsAdminRetireTargets(org, recorded) {
|
|
117
|
+
const role = recorded[ORG_APPS_ADMIN_SCOPE_KEY];
|
|
118
|
+
const pending = Boolean(recorded[ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY]);
|
|
119
|
+
const onAppsDb = Boolean(role?.startsWith(appsDbName(org)));
|
|
120
|
+
return {
|
|
121
|
+
legacy: (Boolean(role) && !onAppsDb) ||
|
|
122
|
+
(pending && !role && Boolean(recorded[ORG_POSTGRES_SERVER_SCOPE_KEY])),
|
|
123
|
+
// A failed mint may have made the role there. Retiring a role that is not there is a no-op (the script guards with EXISTS).
|
|
124
|
+
current: onAppsDb ||
|
|
125
|
+
(Boolean(recorded[APPS_DB_SCOPE_KEYS.app]) &&
|
|
126
|
+
(pending || Boolean(recorded[ORG_APPS_ADMIN_TOUCHED_SCOPE_KEY]))),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
90
129
|
/**
|
|
91
130
|
* Pure. F4 (#925): the wiring of {@link planOrgAppsAdminStep} to `org.boule.apps`. Apps on: the mint
|
|
92
|
-
* step, once Boule (which receives the URL) and the
|
|
131
|
+
* step, once Boule (which receives the URL) and the apps database are recorded or planned. Apps off with
|
|
93
132
|
* the role recorded (or a mint never delivered): the retire step, which needs no secret and drops nothing.
|
|
94
133
|
*/
|
|
95
134
|
export function planAppsAdmin(org, recordedIds, plannedSteps, options) {
|
|
@@ -97,12 +136,15 @@ export function planAppsAdmin(org, recordedIds, plannedSteps, options) {
|
|
|
97
136
|
const blocked = [];
|
|
98
137
|
if (!appsOn(org)) {
|
|
99
138
|
if (!recordedIds[ORG_APPS_ADMIN_SCOPE_KEY] &&
|
|
100
|
-
!recordedIds[ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY]
|
|
139
|
+
!recordedIds[ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY] &&
|
|
140
|
+
!recordedIds[ORG_APPS_ADMIN_TOUCHED_SCOPE_KEY]) {
|
|
101
141
|
return { steps: [], blocked };
|
|
102
142
|
}
|
|
103
|
-
|
|
143
|
+
const targets = appsAdminRetireTargets(org, recordedIds);
|
|
144
|
+
if ((targets.legacy && !recordedIds[ORG_POSTGRES_SERVER_SCOPE_KEY]) ||
|
|
145
|
+
(targets.current && !recordedIds[APPS_DB_SCOPE_KEYS.app])) {
|
|
104
146
|
blocked.push({
|
|
105
|
-
description: `${org.slug}'s apps admin role is not retired, not planned:
|
|
147
|
+
description: `${org.slug}'s apps admin role is not retired, not planned: the server it is on is not recorded`,
|
|
106
148
|
key,
|
|
107
149
|
});
|
|
108
150
|
return { steps: [], blocked };
|
|
@@ -128,15 +170,15 @@ export function planAppsAdmin(org, recordedIds, plannedSteps, options) {
|
|
|
128
170
|
}
|
|
129
171
|
const bouleReady = Boolean(recordedIds[`coolify:boule:${org.id}`]) ||
|
|
130
172
|
plannedSteps.some((p) => p.action.system === 'coolify' && p.action.kind === 'boule' && p.action.key === org.id);
|
|
131
|
-
if (!bouleReady &&
|
|
173
|
+
if (!bouleReady && recordedIds[ORG_APPS_ADMIN_SCOPE_KEY] !== appsAdminDoneValue(org)) {
|
|
132
174
|
blocked.push({
|
|
133
175
|
description: `${org.slug}'s apps admin role is blocked, not planned: Boule is not recorded yet`,
|
|
134
176
|
key,
|
|
135
177
|
});
|
|
136
178
|
return { steps: [], blocked };
|
|
137
179
|
}
|
|
138
|
-
const seen = options.
|
|
139
|
-
? { ...recordedIds, [
|
|
180
|
+
const seen = options.appsDbPlanned && !appsDbRecorded(recordedIds)
|
|
181
|
+
? { ...recordedIds, [APPS_DB_SCOPE_KEYS.app]: 'planned', [APPS_DB_SCOPE_KEYS.tls]: 'planned' }
|
|
140
182
|
: recordedIds;
|
|
141
183
|
const planned = planOrgAppsAdminStep(org, seen, { canHoldSecrets: options.canHoldSecrets });
|
|
142
184
|
if (planned.blocked)
|
|
@@ -144,8 +186,8 @@ export function planAppsAdmin(org, recordedIds, plannedSteps, options) {
|
|
|
144
186
|
return { steps: planned.step ? [planned.step] : [], blocked };
|
|
145
187
|
}
|
|
146
188
|
/**
|
|
147
|
-
* The job's script: makes the role (idempotent, kept if it already exists), sets its password
|
|
148
|
-
*
|
|
189
|
+
* The job's script: makes the role (idempotent, kept if it already exists), sets its password, closes
|
|
190
|
+
* `postgres` and `template1` to PUBLIC and grants the role CONNECT on `postgres` (see the file comment). Roles are made by
|
|
149
191
|
* `format('%I')` and the password by `format('%L')`, so no value is spliced into SQL by the shell. Setting
|
|
150
192
|
* the password again is harmless, so a mint that is repeated simply replaces it. It never prints a URL or a password: psql's output is discarded and the one
|
|
151
193
|
* error line names neither.
|
|
@@ -170,6 +212,9 @@ export function orgAppsAdminScript(role, mode = 'mint') {
|
|
|
170
212
|
'\\getenv pw PW',
|
|
171
213
|
"SELECT format('CREATE ROLE %I LOGIN CREATEDB CREATEROLE', :'role') WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'role') \\gexec",
|
|
172
214
|
"SELECT format('ALTER ROLE %I LOGIN CREATEDB CREATEROLE PASSWORD %L', :'role', :'pw') \\gexec",
|
|
215
|
+
// The apps database's own `postgres` and `template1` are closed to every app role; Boule closes each app database it makes.
|
|
216
|
+
'REVOKE CONNECT ON DATABASE postgres FROM PUBLIC;',
|
|
217
|
+
'REVOKE CONNECT ON DATABASE template1 FROM PUBLIC;',
|
|
173
218
|
"SELECT format('GRANT CONNECT ON DATABASE postgres TO %I', :'role') \\gexec",
|
|
174
219
|
'SQL',
|
|
175
220
|
'echo "org-apps-admin: role ready"',
|
|
@@ -226,26 +271,25 @@ export async function provisionOrgAppsAdmin(scope, deps, org) {
|
|
|
226
271
|
const label = `${org.slug}'s apps admin role`;
|
|
227
272
|
const coolify = withCallTimeout(deps.coolify);
|
|
228
273
|
const recorded = scope.recordedIds();
|
|
229
|
-
if (recorded[ORG_APPS_ADMIN_SCOPE_KEY])
|
|
274
|
+
if (recorded[ORG_APPS_ADMIN_SCOPE_KEY] === appsAdminDoneValue(org))
|
|
230
275
|
return;
|
|
231
|
-
const serverId = recorded[
|
|
232
|
-
if (!serverId)
|
|
233
|
-
throw new Error(`${label}: the
|
|
276
|
+
const serverId = recorded[APPS_DB_SCOPE_KEYS.app];
|
|
277
|
+
if (!serverId || !recorded[APPS_DB_SCOPE_KEYS.tls]) {
|
|
278
|
+
throw new Error(`${label}: the apps database is not recorded yet`);
|
|
279
|
+
}
|
|
234
280
|
if (recorded[ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY]) {
|
|
235
281
|
throw new Error(`${label}: an earlier run set a new password on the role but never saved its connection URL. Nothing is lost. To start over, turn apps off and apply (that retires the role and clears this note), then turn apps on again`);
|
|
236
282
|
}
|
|
237
283
|
const onSecret = (value) => deps.onSecret?.(value);
|
|
238
|
-
const
|
|
239
|
-
if (!server.internal_db_url || !URL.canParse(server.internal_db_url)) {
|
|
240
|
-
throw new Error(`${label}: the Postgres server has no usable internal URL`);
|
|
241
|
-
}
|
|
242
|
-
const adminUrl = server.internal_db_url;
|
|
284
|
+
const adminUrl = await boundedCall(deps.appsDbAdminUrl(serverId), 'read the apps database address');
|
|
243
285
|
onSecret(adminUrl);
|
|
286
|
+
if (!URL.canParse(adminUrl))
|
|
287
|
+
throw new Error(`${label}: the apps database has no usable address`);
|
|
244
288
|
const adminParsed = new URL(adminUrl);
|
|
245
289
|
const host = adminParsed.hostname;
|
|
246
290
|
const port = adminParsed.port || '5432';
|
|
247
291
|
if (!host)
|
|
248
|
-
throw new Error(`${label}: the
|
|
292
|
+
throw new Error(`${label}: the apps database's address has no host`);
|
|
249
293
|
const adminPassword = decodeURIComponent(adminParsed.password);
|
|
250
294
|
if (adminPassword)
|
|
251
295
|
onSecret(adminPassword);
|
|
@@ -312,6 +356,12 @@ export async function provisionOrgAppsAdmin(scope, deps, org) {
|
|
|
312
356
|
'; its env could not be cleared, delete the app in Coolify (it holds the admin URL)';
|
|
313
357
|
}
|
|
314
358
|
}
|
|
359
|
+
// The job is idempotent (the role is made only if missing, the password is set again), and a failed run
|
|
360
|
+
// delivered nothing, so a plan that mints again loses nothing: the marker is cleared and the next apply repeats.
|
|
361
|
+
scope.recordScopeId(ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY, '');
|
|
362
|
+
// The role may exist by now (the job can fail after CREATE ROLE): apps off must retire it there.
|
|
363
|
+
scope.recordScopeId(ORG_APPS_ADMIN_TOUCHED_SCOPE_KEY, appsDbName(org));
|
|
364
|
+
await scope.persist();
|
|
315
365
|
throw new Error(`${label}: ${why}${jobAppId ? `; the job app ${jobAppId} is left stopped without secrets for inspection and the next apply replaces it${cleaned}` : ''}; apply again`);
|
|
316
366
|
}
|
|
317
367
|
try {
|
|
@@ -320,7 +370,7 @@ export async function provisionOrgAppsAdmin(scope, deps, org) {
|
|
|
320
370
|
catch {
|
|
321
371
|
throw new Error(`${label}: the role was made, but its job app ${jobAppId} could not be deleted; delete it in Coolify (it holds the admin URL), then apply again`);
|
|
322
372
|
}
|
|
323
|
-
const url = `postgres://${encodeURIComponent(role)}:${encodeURIComponent(password)}@${host}:${port}/postgres`;
|
|
373
|
+
const url = `postgres://${encodeURIComponent(role)}:${encodeURIComponent(password)}@${host}:${port}/postgres?sslmode=require`;
|
|
324
374
|
onSecret(url);
|
|
325
375
|
let delivered = false;
|
|
326
376
|
for (let attempt = 1; attempt <= ORG_APPS_ADMIN_DELIVERY_ATTEMPTS && !delivered; attempt++) {
|
|
@@ -335,8 +385,9 @@ export async function provisionOrgAppsAdmin(scope, deps, org) {
|
|
|
335
385
|
if (!delivered) {
|
|
336
386
|
throw new Error(`${label}: the role was made, but its connection URL could not be saved after ${ORG_APPS_ADMIN_DELIVERY_ATTEMPTS} attempts. Nothing is lost. Turn apps off and apply (that retires the role and clears the note), then turn apps on again`);
|
|
337
387
|
}
|
|
338
|
-
scope.recordScopeId(ORG_APPS_ADMIN_SCOPE_KEY,
|
|
388
|
+
scope.recordScopeId(ORG_APPS_ADMIN_SCOPE_KEY, appsAdminDoneValue(org));
|
|
339
389
|
scope.recordScopeId(ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY, '');
|
|
390
|
+
scope.recordScopeId(ORG_APPS_ADMIN_TOUCHED_SCOPE_KEY, '');
|
|
340
391
|
await scope.persist();
|
|
341
392
|
scope.recordScopeId(ORG_APPS_ADMIN_JOB_APP_SCOPE_KEY, '');
|
|
342
393
|
}
|
|
@@ -351,20 +402,15 @@ export async function retireOrgAppsAdmin(scope, deps, org) {
|
|
|
351
402
|
const label = `${org.slug}'s apps admin role`;
|
|
352
403
|
const coolify = withCallTimeout(deps.coolify);
|
|
353
404
|
const recorded = scope.recordedIds();
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
throw new Error(`${label}: the Postgres server has no usable internal URL`);
|
|
405
|
+
const onSecret = (value) => deps.onSecret?.(value);
|
|
406
|
+
/** Runs the retire job against the server `adminUrl` names. */
|
|
407
|
+
const retireOn = async (adminUrl) => {
|
|
408
|
+
onSecret(adminUrl);
|
|
409
|
+
if (URL.canParse(adminUrl)) {
|
|
410
|
+
const password = decodeURIComponent(new URL(adminUrl).password);
|
|
411
|
+
if (password)
|
|
412
|
+
onSecret(password);
|
|
363
413
|
}
|
|
364
|
-
onSecret(server.internal_db_url);
|
|
365
|
-
const password = decodeURIComponent(new URL(server.internal_db_url).password);
|
|
366
|
-
if (password)
|
|
367
|
-
onSecret(password);
|
|
368
414
|
const jobName = appsAdminJobName(org);
|
|
369
415
|
let jobAppId;
|
|
370
416
|
try {
|
|
@@ -390,9 +436,7 @@ export async function retireOrgAppsAdmin(scope, deps, org) {
|
|
|
390
436
|
scope.recordScopeId(ORG_APPS_ADMIN_JOB_APP_SCOPE_KEY, jobAppId);
|
|
391
437
|
await scope.persist();
|
|
392
438
|
await coolify.updateApplication(jobAppId, { health_check_enabled: false });
|
|
393
|
-
await coolify.bulkUpsertEnvs(jobAppId, [
|
|
394
|
-
{ key: 'ADMIN_DATABASE_URL', value: server.internal_db_url },
|
|
395
|
-
]);
|
|
439
|
+
await coolify.bulkUpsertEnvs(jobAppId, [{ key: 'ADMIN_DATABASE_URL', value: adminUrl }]);
|
|
396
440
|
await coolify.deploy(jobAppId, true);
|
|
397
441
|
await waitForJob(coolify, jobAppId, deps.runtime ?? {}, {
|
|
398
442
|
job: 'the apps admin retire job',
|
|
@@ -420,12 +464,31 @@ export async function retireOrgAppsAdmin(scope, deps, org) {
|
|
|
420
464
|
catch {
|
|
421
465
|
throw new Error(`${label}: the role was retired, but its job app ${jobAppId} could not be deleted; delete it in Coolify (it holds the admin URL), then apply again`);
|
|
422
466
|
}
|
|
467
|
+
};
|
|
468
|
+
const targets = appsAdminRetireTargets(org, recorded);
|
|
469
|
+
if (targets.legacy) {
|
|
470
|
+
const serverId = recorded[ORG_POSTGRES_SERVER_SCOPE_KEY];
|
|
471
|
+
if (!serverId) {
|
|
472
|
+
throw new Error(`${label}: the Postgres server is not recorded; cannot retire it`);
|
|
473
|
+
}
|
|
474
|
+
const server = await boundedCall(deps.startDatabase(serverId), 'Coolify startDatabase');
|
|
475
|
+
if (!server.internal_db_url || !URL.canParse(server.internal_db_url)) {
|
|
476
|
+
throw new Error(`${label}: the Postgres server has no usable internal URL`);
|
|
477
|
+
}
|
|
478
|
+
await retireOn(server.internal_db_url);
|
|
479
|
+
}
|
|
480
|
+
if (targets.current) {
|
|
481
|
+
const appId = recorded[APPS_DB_SCOPE_KEYS.app];
|
|
482
|
+
if (!appId)
|
|
483
|
+
throw new Error(`${label}: the apps database is not recorded; cannot retire it`);
|
|
484
|
+
await retireOn(await boundedCall(deps.appsDbAdminUrl(appId), 'read the apps database address'));
|
|
423
485
|
}
|
|
424
486
|
await deps.removeBouleEnv();
|
|
425
487
|
await deps.clearHeldUrl(appsAdminUrlRef(org));
|
|
426
488
|
for (const key of [
|
|
427
489
|
ORG_APPS_ADMIN_SCOPE_KEY,
|
|
428
490
|
ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY,
|
|
491
|
+
ORG_APPS_ADMIN_TOUCHED_SCOPE_KEY,
|
|
429
492
|
ORG_APPS_ADMIN_JOB_APP_SCOPE_KEY,
|
|
430
493
|
]) {
|
|
431
494
|
scope.recordScopeId(key, '');
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -2645,6 +2645,7 @@ const STEP_FIELDS = {
|
|
|
2645
2645
|
'coolify:own-auth': 'services.own',
|
|
2646
2646
|
'coolify:own-auth-db-move': 'services.own',
|
|
2647
2647
|
'coolify:worker-app-db': 'hosting.entries.workerApp.database',
|
|
2648
|
+
'coolify:apps-db': 'boule.apps',
|
|
2648
2649
|
'coolify:own-files': 'services.own',
|
|
2649
2650
|
'coolify:own-files-r2': 'services.own',
|
|
2650
2651
|
'coolify:own-jobs': 'services.own',
|
|
@@ -88,6 +88,7 @@ export declare function signinDbBackupEnvs(target: SigninDbBackupTarget, slug: s
|
|
|
88
88
|
user: string;
|
|
89
89
|
database: string;
|
|
90
90
|
prefixBase?: string;
|
|
91
|
+
dumpAll?: boolean;
|
|
91
92
|
}): Array<{
|
|
92
93
|
key: string;
|
|
93
94
|
value: string;
|
|
@@ -98,8 +99,11 @@ export declare function signinDbBackupEnvs(target: SigninDbBackupTarget, slug: s
|
|
|
98
99
|
* credentials reach curl on its standard input as a config, never in an argument. Every failure ends the
|
|
99
100
|
* run non-zero, and curl's error text names the HTTP status only. It lives in the app's env, base64,
|
|
100
101
|
* because Coolify stores a task's command in 255 characters and this script is longer.
|
|
102
|
+
*
|
|
103
|
+
* `all` dumps the whole server with `pg_dumpall` (plain SQL, roles included) instead of one database in
|
|
104
|
+
* custom format: the company's apps server holds a database per app copy and none worth dumping in `postgres`.
|
|
101
105
|
*/
|
|
102
|
-
export declare function signinDbBackupScript(user: string, database: string): string;
|
|
106
|
+
export declare function signinDbBackupScript(user: string, database: string, all?: boolean): string;
|
|
103
107
|
/** The most Coolify stores in a scheduled task's command (`varchar(255)`); a longer one answers 500. */
|
|
104
108
|
export declare const COOLIFY_TASK_COMMAND_MAX = 255;
|
|
105
109
|
/**
|
|
@@ -129,7 +129,7 @@ export function signinDbBackupEnvs(target, slug, db) {
|
|
|
129
129
|
{ key: SIGNIN_DB_BACKUP_ENV.prefix, value: signinDbBackupPrefix(slug, db.prefixBase) },
|
|
130
130
|
{
|
|
131
131
|
key: SIGNIN_DB_BACKUP_ENV.script,
|
|
132
|
-
value: Buffer.from(signinDbBackupScript(db.user, db.database), 'utf8').toString('base64'),
|
|
132
|
+
value: Buffer.from(signinDbBackupScript(db.user, db.database, db.dumpAll === true), 'utf8').toString('base64'),
|
|
133
133
|
},
|
|
134
134
|
];
|
|
135
135
|
}
|
|
@@ -139,14 +139,17 @@ export function signinDbBackupEnvs(target, slug, db) {
|
|
|
139
139
|
* credentials reach curl on its standard input as a config, never in an argument. Every failure ends the
|
|
140
140
|
* run non-zero, and curl's error text names the HTTP status only. It lives in the app's env, base64,
|
|
141
141
|
* because Coolify stores a task's command in 255 characters and this script is longer.
|
|
142
|
+
*
|
|
143
|
+
* `all` dumps the whole server with `pg_dumpall` (plain SQL, roles included) instead of one database in
|
|
144
|
+
* custom format: the company's apps server holds a database per app copy and none worth dumping in `postgres`.
|
|
142
145
|
*/
|
|
143
|
-
export function signinDbBackupScript(user, database) {
|
|
146
|
+
export function signinDbBackupScript(user, database, all = false) {
|
|
144
147
|
const e = SIGNIN_DB_BACKUP_ENV;
|
|
145
148
|
return [
|
|
146
149
|
'set -eu',
|
|
147
|
-
|
|
150
|
+
`f=/tmp/signin-db-$(date -u +%Y%m%dT%H%M%SZ).${all ? 'sql' : 'dump'}`,
|
|
148
151
|
'trap "rm -f $f" EXIT',
|
|
149
|
-
`pg_dump -U ${user} -d ${database} -Fc -f "$f"`,
|
|
152
|
+
all ? `pg_dumpall -U ${user} -f "$f"` : `pg_dump -U ${user} -d ${database} -Fc -f "$f"`,
|
|
150
153
|
`printf "user = \\"%s:%s\\"\\n" "$${e.keyId}" "$${e.secret}" | curl -sS --fail --max-time ${UPLOAD_MAX_SECONDS} --aws-sigv4 "aws:amz:$${e.region}:s3" -K - -T "$f" "$${e.endpoint}/$${e.bucket}/$${e.prefix}/\${f##*/}"`,
|
|
151
154
|
].join('; ');
|
|
152
155
|
}
|
package/dist/plan/signin-db.d.ts
CHANGED
|
@@ -118,6 +118,8 @@ export interface TlsDbOptions {
|
|
|
118
118
|
scopeKeys?: TlsDbScopeKeys;
|
|
119
119
|
/** The folder in the bucket before the slug (default `signin-db`). */
|
|
120
120
|
backupPrefix?: string;
|
|
121
|
+
/** The nightly dump takes the whole server (`pg_dumpall`), not `database` alone. */
|
|
122
|
+
dumpAll?: boolean;
|
|
121
123
|
}
|
|
122
124
|
/**
|
|
123
125
|
* Brings the TLS database up and returns ZITADEL's DSN: the CA and server certificate (held in
|
package/dist/plan/signin-db.js
CHANGED
|
@@ -239,6 +239,12 @@ export interface WorkerAppPlanInput {
|
|
|
239
239
|
noDeleteCheck?: boolean;
|
|
240
240
|
};
|
|
241
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* The company's apps database route (`apps-db-route.ts`) records its parts under this entry key with
|
|
244
|
+
* the Worker app database helpers, so {@link liveWorkerApps} lists it. It is not a Worker app: apps
|
|
245
|
+
* switched off retire it, never the undeclared-Worker-app step.
|
|
246
|
+
*/
|
|
247
|
+
export declare const APPS_DB_ENTRY_KEY = "apps-db";
|
|
242
248
|
/** Every entry key with a deployed or half-deployed Worker app recorded. */
|
|
243
249
|
export declare function liveWorkerApps(orgId: string, recordedIds: Readonly<Record<string, string>>): Map<string, string>;
|
|
244
250
|
/**
|
package/dist/plan/worker-app.js
CHANGED
|
@@ -237,6 +237,12 @@ export function migrationPrefixError(applied, declared) {
|
|
|
237
237
|
}
|
|
238
238
|
/** The unapplied migrations after the last tag in `applied`. */
|
|
239
239
|
export const unappliedMigrations = (declared, applied) => declared.slice(applied.length);
|
|
240
|
+
/**
|
|
241
|
+
* The company's apps database route (`apps-db-route.ts`) records its parts under this entry key with
|
|
242
|
+
* the Worker app database helpers, so {@link liveWorkerApps} lists it. It is not a Worker app: apps
|
|
243
|
+
* switched off retire it, never the undeclared-Worker-app step.
|
|
244
|
+
*/
|
|
245
|
+
export const APPS_DB_ENTRY_KEY = 'apps-db';
|
|
240
246
|
/** Every entry key with a deployed or half-deployed Worker app recorded. */
|
|
241
247
|
export function liveWorkerApps(orgId, recordedIds) {
|
|
242
248
|
const mainPrefix = `cloudflare:worker-app:${orgId}:`;
|
|
@@ -291,7 +297,7 @@ export function planWorkerApps(org, recordedIds, input) {
|
|
|
291
297
|
const blocked = [];
|
|
292
298
|
const declared = new Set(org.hosting.enabled ? org.hosting.entries.filter((e) => e.workerApp).map((e) => e.key) : []);
|
|
293
299
|
for (const entryKey of liveWorkerApps(org.id, recordedIds).keys()) {
|
|
294
|
-
if (declared.has(entryKey))
|
|
300
|
+
if (declared.has(entryKey) || entryKey === APPS_DB_ENTRY_KEY)
|
|
295
301
|
continue;
|
|
296
302
|
const key = workerAppResourceKey(`${org.id}:${entryKey}`);
|
|
297
303
|
if (!input.available) {
|
package/dist/store/postgres.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { and, asc, desc, eq, gte, inArray, isNull, ne, sql } from 'drizzle-orm';
|
|
2
|
-
import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, normalizeHostingRepos, refuseBuiltinBlueGreen, refuseCustomers, refuseHosting, validateBlueGreen, validateBuyerIdentity, validateCalls, validateDomain, validateDomainClaim, validateMail, validateOwners, validateServices, validateSignIn, validateSite, validateVersionPin, } from '../org.js';
|
|
2
|
+
import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, normalizeHostingRepos, refuseBuiltinBlueGreen, refuseCustomers, refuseHosting, validateBlueGreen, validateBuyerIdentity, validateCalls, validateDomain, validateDomainClaim, validateMail, validateOwners, validateServices, validateSignIn, validateSite, validateVersionPin, withNewOrgAppsDefault, } from '../org.js';
|
|
3
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';
|
|
@@ -486,7 +486,10 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
486
486
|
}
|
|
487
487
|
// `as`: `update` is overloaded in the interface (#351), which one object-literal method cannot express.
|
|
488
488
|
return {
|
|
489
|
-
async create(
|
|
489
|
+
async create(given) {
|
|
490
|
+
// #1041: fills `boule.apps` in only while the caller declares no `boule` of its own -- never on
|
|
491
|
+
// `update`, so an existing company's stored value (on or off) is read back exactly as it is.
|
|
492
|
+
const org = withNewOrgAppsDefault(given);
|
|
490
493
|
requireValidSlug(org.slug);
|
|
491
494
|
requireValidDomain(org.domain);
|
|
492
495
|
requireValidLimits(org);
|