@wtfalch/foundry 0.16.7 → 0.16.9
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/bootstrap/bootstrap.d.ts +2 -0
- package/dist/bootstrap/bootstrap.js +9 -1
- package/dist/bootstrap/declare.d.ts +1 -1
- package/dist/bootstrap/declare.js +7 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/migrations/0036_applied_versions.sql +45 -0
- package/dist/migrations/0037_platform_server_ipv4.sql +8 -0
- package/dist/org.d.ts +39 -0
- package/dist/org.js +46 -1
- package/dist/plan/boule.d.ts +8 -2
- package/dist/plan/boule.js +10 -1
- package/dist/plan/diff.d.ts +2 -0
- package/dist/plan/diff.js +56 -7
- package/dist/plan/executor.d.ts +50 -6
- package/dist/plan/executor.js +95 -21
- package/dist/plan/plan-apply.d.ts +21 -1
- package/dist/plan/plan-apply.js +38 -3
- package/dist/plan/platform-diff.d.ts +5 -1
- package/dist/plan/platform-diff.js +17 -5
- package/dist/plan/platform-providers.d.ts +55 -3
- package/dist/plan/platform-providers.js +368 -58
- package/dist/platform.d.ts +11 -0
- package/dist/platform.js +5 -0
- package/dist/store/postgres.d.ts +9 -1
- package/dist/store/postgres.js +36 -2
- package/dist/store/schema.d.ts +134 -0
- package/dist/store/schema.js +15 -0
- package/dist/store.d.ts +37 -0
- package/package.json +1 -1
|
@@ -4,6 +4,8 @@ import type { FoundryStoreDb } from '../store/postgres.js';
|
|
|
4
4
|
import type { BootstrapCompany, BootstrapOwner } from './declare.js';
|
|
5
5
|
export interface BootstrapPlatformInput {
|
|
6
6
|
company: BootstrapCompany;
|
|
7
|
+
/** The Coolify server's public IPv4, saved as `platform.serverIpv4` (`../platform.ts`); absent leaves a stored value alone. */
|
|
8
|
+
serverIpv4?: string;
|
|
7
9
|
owner: BootstrapOwner;
|
|
8
10
|
}
|
|
9
11
|
/**
|
|
@@ -198,9 +198,17 @@ export async function bootstrapPlatform(input, deps) {
|
|
|
198
198
|
const existing = await platformStore.get();
|
|
199
199
|
if (existing) {
|
|
200
200
|
emit({ phase: 'declare', message: `declared platform already written for ${existing.apex}` });
|
|
201
|
+
// Only serverIpv4, through the validating `put`; no other field is touched.
|
|
202
|
+
if (input.serverIpv4 && input.serverIpv4 !== existing.serverIpv4) {
|
|
203
|
+
await platformStore.put({ ...existing, serverIpv4: input.serverIpv4 });
|
|
204
|
+
emit({
|
|
205
|
+
phase: 'declare',
|
|
206
|
+
message: `declared platform serverIpv4 set to ${input.serverIpv4}`,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
201
209
|
}
|
|
202
210
|
else {
|
|
203
|
-
await platformStore.put(declaredPlatformFor(input.company.apex));
|
|
211
|
+
await platformStore.put(declaredPlatformFor(input.company.apex, input.serverIpv4));
|
|
204
212
|
emit({
|
|
205
213
|
phase: 'declare',
|
|
206
214
|
message: `declared platform written for ${input.company.apex}`,
|
|
@@ -35,4 +35,4 @@ export declare function declaredCompanyOrg(company: BootstrapCompany, owner: Boo
|
|
|
35
35
|
* The declared platform under `apex`. A disabled service keeps an empty
|
|
36
36
|
* `gitRef`, which `validatePlatform` allows.
|
|
37
37
|
*/
|
|
38
|
-
export declare function declaredPlatformFor(apex: string): DeclaredPlatform;
|
|
38
|
+
export declare function declaredPlatformFor(apex: string, serverIpv4?: string): DeclaredPlatform;
|
|
@@ -85,7 +85,7 @@ export function declaredCompanyOrg(company, owner) {
|
|
|
85
85
|
* The declared platform under `apex`. A disabled service keeps an empty
|
|
86
86
|
* `gitRef`, which `validatePlatform` allows.
|
|
87
87
|
*/
|
|
88
|
-
export function declaredPlatformFor(apex) {
|
|
88
|
+
export function declaredPlatformFor(apex, serverIpv4) {
|
|
89
89
|
const services = {};
|
|
90
90
|
for (const key of PLATFORM_SERVICE_KEYS) {
|
|
91
91
|
const enabled = SERVICES_ENABLED_BY_DEFAULT.has(key);
|
|
@@ -95,5 +95,10 @@ export function declaredPlatformFor(apex) {
|
|
|
95
95
|
gitRef: !enabled ? '' : key === 'mail' ? DEFAULT_MAIL_IMAGE_TAG : DEFAULT_GIT_REF,
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
|
-
return {
|
|
98
|
+
return {
|
|
99
|
+
ownerOrgId: COMPANY_INCLUDED_ORG_ID,
|
|
100
|
+
apex,
|
|
101
|
+
...(serverIpv4 ? { serverIpv4 } : {}),
|
|
102
|
+
...services,
|
|
103
|
+
};
|
|
99
104
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -8,12 +8,12 @@ export type { SlugRejectionReason, SlugRejection } from './slug.js';
|
|
|
8
8
|
export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
|
|
9
9
|
export type { Checkpoint, LastApplied, AppliedState } from './state.js';
|
|
10
10
|
export type { PlanStep, UnmanagedItem, Plan } from './plan.js';
|
|
11
|
-
export type { DeclaredOrgStore, PlatformStore, RequestedChangeStore, StaleUpdate, UpdateOptions, } from './store.js';
|
|
11
|
+
export type { AppliedVersion, AppliedVersionsStore, DeclaredOrgStore, PlatformStore, RequestedChangeStore, StaleUpdate, UpdateOptions, } from './store.js';
|
|
12
12
|
export type { PlanApply } from './apply.js';
|
|
13
|
-
export { createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
13
|
+
export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
14
14
|
export { createPostgresAuditLog } from './store/audit.js';
|
|
15
15
|
export type { FoundryStoreDb } from './store/postgres.js';
|
|
16
|
-
export { declaredOrgs, foundryAppliedState, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
|
|
16
|
+
export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type AppliedVersionRow, type NewAppliedVersionRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
|
|
17
17
|
export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, PlanDetail, PlatformPlanDetail, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
|
|
18
18
|
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
19
19
|
export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
|
package/dist/index.js
CHANGED
|
@@ -2,9 +2,9 @@ export { APP_ENTRY_KEY, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_M
|
|
|
2
2
|
export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
|
|
3
3
|
export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
|
|
4
4
|
export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
|
|
5
|
-
export { createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
5
|
+
export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
6
6
|
export { createPostgresAuditLog } from './store/audit.js';
|
|
7
|
-
export { declaredOrgs, foundryAppliedState, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
|
|
7
|
+
export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
|
|
8
8
|
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
9
9
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
|
|
10
10
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
-- @wtfalch/foundry: a history of recorded applies, sitting beside
|
|
2
|
+
-- `0002_applied_state.sql`'s single current snapshot (#253, building on the
|
|
3
|
+
-- upgrade-safety map, #237). `foundry_applied_state` (and the platform's own
|
|
4
|
+
-- `foundry_declared_platform.service_ids`) replace their one row in place on
|
|
5
|
+
-- every apply, so once a new version is recorded the one it replaced is
|
|
6
|
+
-- gone -- there is no "previous version" to show or to roll back to, and a
|
|
7
|
+
-- failed apply leaves no trail of what came before it either. This table
|
|
8
|
+
-- never replaces a row; every recorded version is a new one, appended.
|
|
9
|
+
--
|
|
10
|
+
-- `service` names whatever was just recorded as applied -- today, an org's
|
|
11
|
+
-- id, from the same point in `plan/plan-apply.ts`'s `runSteps` that writes
|
|
12
|
+
-- `AppliedState.lastApplied` (mirrors `0002`'s `last_applied_declared_org_version`
|
|
13
|
+
-- renamed generically, since #237's platform services -- mail, archon, ai,
|
|
14
|
+
-- files, integrations and the rest -- record an applied ref of their own and
|
|
15
|
+
-- can write rows here the same way once they do). `ref` is whatever version
|
|
16
|
+
-- identifier that apply recorded (an org's declared-org hash today; a git
|
|
17
|
+
-- ref or image tag for a platform service).
|
|
18
|
+
--
|
|
19
|
+
-- `run_id` and `backup_id` are both nullable: nothing in today's code ties
|
|
20
|
+
-- an apply to a run id (the job runner, `src/runner`, is not yet wired to
|
|
21
|
+
-- plan/apply -- #17's own module, standing alone), and no apply takes a
|
|
22
|
+
-- backup before recording a version yet -- #256 ("Back up before a stateful
|
|
23
|
+
-- upgrade") is the later issue that starts writing `backup_id`. Both columns
|
|
24
|
+
-- exist now so that issue, and a future run-id wiring, are additive: a new
|
|
25
|
+
-- column would be, this is a new row shape instead.
|
|
26
|
+
--
|
|
27
|
+
-- Read back by `previousAppliedRef` (`../store.ts`): the ref of the
|
|
28
|
+
-- second-most-recent row for a service is what a rollback has to roll back
|
|
29
|
+
-- to. No foreign key to `foundry_declared_orgs` -- `foundry_runs.org_id`
|
|
30
|
+
-- makes the same choice (`0003_foundry_runs.sql`) -- because `service` is
|
|
31
|
+
-- not always an org id, and a history row for a destroyed org (or a
|
|
32
|
+
-- platform service, which is no org at all) must survive the org going away.
|
|
33
|
+
|
|
34
|
+
create table if not exists foundry_applied_versions (
|
|
35
|
+
id bigserial primary key,
|
|
36
|
+
service text not null,
|
|
37
|
+
ref text not null,
|
|
38
|
+
applied_at timestamptz not null default now(),
|
|
39
|
+
run_id text,
|
|
40
|
+
backup_id text
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
-- `previousAppliedRef`'s read: the most recent rows for one service, newest first.
|
|
44
|
+
create index if not exists foundry_applied_versions_service_idx
|
|
45
|
+
on foundry_applied_versions (service, applied_at desc, id desc);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- @wtfalch/foundry: the declared platform's optional public server IPv4.
|
|
2
|
+
-- Coolify's localhost server reports `host.docker.internal` as its ip, which
|
|
3
|
+
-- cannot be a DNS A record, so the operator declares the real address here
|
|
4
|
+
-- instead of hand-setting it in Coolify. Format is validated by
|
|
5
|
+
-- src/platform.ts's `validatePlatform`, not here (same split as `apex`).
|
|
6
|
+
-- Null: use the Coolify server's own ip when it is an IPv4.
|
|
7
|
+
|
|
8
|
+
alter table foundry_declared_platform add column if not exists server_ipv4 text;
|
package/dist/org.d.ts
CHANGED
|
@@ -209,7 +209,46 @@ export interface HostingConfig {
|
|
|
209
209
|
issuerApps: IssuerApp[];
|
|
210
210
|
/** One entry per app the org runs. */
|
|
211
211
|
entries: HostingEntry[];
|
|
212
|
+
/**
|
|
213
|
+
* Cloudflare Access rules for hostnames besides the `app` entry's own
|
|
214
|
+
* (#246): `accessEmails` above already gates that one hostname by an
|
|
215
|
+
* email list; this says what any other hostname an org serves (e.g.
|
|
216
|
+
* `share.<domain>`, `storage.<domain>`) gets instead of an operator
|
|
217
|
+
* setting it up by hand in the Cloudflare dashboard, which is how it is
|
|
218
|
+
* done today. Empty or absent: no additional Access apps.
|
|
219
|
+
*/
|
|
220
|
+
access?: AccessRule[];
|
|
212
221
|
}
|
|
222
|
+
/** The three Cloudflare Access rule kinds {@link HostingConfig.access} supports (#246). */
|
|
223
|
+
export type AccessRuleKind = 'gate' | 'bypass' | 'service-token';
|
|
224
|
+
export declare const ACCESS_RULE_KINDS: ReadonlySet<AccessRuleKind>;
|
|
225
|
+
/**
|
|
226
|
+
* One Cloudflare Access rule on a hostname besides the `app` entry's own
|
|
227
|
+
* (#246, `HostingConfig.access`). `hostname` is the full hostname Access
|
|
228
|
+
* gates (e.g. `share.<domain>`), never an entry key.
|
|
229
|
+
*
|
|
230
|
+
* - `gate`: an email allowlist, the same shape `accessEmails` already
|
|
231
|
+
* applies to the app's own hostname.
|
|
232
|
+
* - `bypass`: no login at all -- the hostname is public through Access.
|
|
233
|
+
* - `service-token`: only a request carrying the named Cloudflare Access
|
|
234
|
+
* service token's headers gets through, no human login flow. The token
|
|
235
|
+
* itself is not minted by this rule -- it names a live Cloudflare Access
|
|
236
|
+
* service token that `executor.ts` looks up by name, the same way its
|
|
237
|
+
* `app`-entry gate finds its Access app by name rather than creating a
|
|
238
|
+
* second one.
|
|
239
|
+
*/
|
|
240
|
+
export type AccessRule = {
|
|
241
|
+
hostname: string;
|
|
242
|
+
kind: 'gate';
|
|
243
|
+
emails: string[];
|
|
244
|
+
} | {
|
|
245
|
+
hostname: string;
|
|
246
|
+
kind: 'bypass';
|
|
247
|
+
} | {
|
|
248
|
+
hostname: string;
|
|
249
|
+
kind: 'service-token';
|
|
250
|
+
serviceTokenName: string;
|
|
251
|
+
};
|
|
213
252
|
/** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
|
|
214
253
|
export declare const APP_ENTRY_KEY = "app";
|
|
215
254
|
/**
|
package/dist/org.js
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
|
|
3
3
|
/** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
|
|
4
4
|
export const DEFAULT_NO_REPLY_LOCAL_PART = 'no-reply';
|
|
5
|
+
export const ACCESS_RULE_KINDS = new Set([
|
|
6
|
+
'gate',
|
|
7
|
+
'bypass',
|
|
8
|
+
'service-token',
|
|
9
|
+
]);
|
|
5
10
|
/** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
|
|
6
11
|
export const APP_ENTRY_KEY = 'app';
|
|
7
12
|
/** What an entry's ai key may hold: the most the org's own key holds. */
|
|
@@ -118,6 +123,45 @@ export function validateHosting(hosting, names) {
|
|
|
118
123
|
if (hosting.entries.filter((entry) => entry.database).length > 1) {
|
|
119
124
|
return 'at most one hosting entry may set database: the one runtime database role cannot serve two apps';
|
|
120
125
|
}
|
|
126
|
+
const seenAccessHostnames = new Set();
|
|
127
|
+
for (const rule of hosting.access ?? []) {
|
|
128
|
+
const invalidRule = validateAccessRule(rule, hosting.enabled);
|
|
129
|
+
if (invalidRule)
|
|
130
|
+
return invalidRule;
|
|
131
|
+
if (seenAccessHostnames.has(rule.hostname)) {
|
|
132
|
+
return `hosting.access hostname '${rule.hostname}' is listed twice`;
|
|
133
|
+
}
|
|
134
|
+
seenAccessHostnames.add(rule.hostname);
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
/** A full DNS hostname, at least two labels -- distinct from an entry's single-label `key`. */
|
|
139
|
+
const ACCESS_RULE_HOSTNAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
140
|
+
/** The rules for one {@link AccessRule}; `null` when it is valid. */
|
|
141
|
+
function validateAccessRule(rule, enabled) {
|
|
142
|
+
const at = `hosting.access '${rule.hostname}'`;
|
|
143
|
+
if (!rule.hostname || !ACCESS_RULE_HOSTNAME.test(rule.hostname)) {
|
|
144
|
+
return `hosting.access entries need a full hostname ('${rule.hostname}' is not one)`;
|
|
145
|
+
}
|
|
146
|
+
if (!enabled) {
|
|
147
|
+
return `${at} requires hosting.enabled`;
|
|
148
|
+
}
|
|
149
|
+
if (!ACCESS_RULE_KINDS.has(rule.kind)) {
|
|
150
|
+
return `${at} has an unknown kind '${rule.kind}'`;
|
|
151
|
+
}
|
|
152
|
+
if (rule.kind === 'gate') {
|
|
153
|
+
if (rule.emails.length === 0) {
|
|
154
|
+
return `${at} kind 'gate' needs at least one email`;
|
|
155
|
+
}
|
|
156
|
+
for (const email of rule.emails) {
|
|
157
|
+
if (!EMAIL.test(email)) {
|
|
158
|
+
return `${at} has an invalid email '${email}'`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (rule.kind === 'service-token' && !rule.serviceTokenName) {
|
|
163
|
+
return `${at} kind 'service-token' needs a non-empty serviceTokenName`;
|
|
164
|
+
}
|
|
121
165
|
return null;
|
|
122
166
|
}
|
|
123
167
|
/**
|
|
@@ -164,6 +208,7 @@ function validateEntryHostnames(hosting, names) {
|
|
|
164
208
|
return null;
|
|
165
209
|
}
|
|
166
210
|
const ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
211
|
+
const EMAIL = /^[^@\s]+@[^@\s]+$/;
|
|
167
212
|
const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
|
|
168
213
|
// The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
|
|
169
214
|
const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
|
|
@@ -207,7 +252,7 @@ function validateHostingEntry(entry, enabled) {
|
|
|
207
252
|
return `${at} accessEmails is only available on the '${APP_ENTRY_KEY}' entry for now`;
|
|
208
253
|
}
|
|
209
254
|
for (const email of entry.accessEmails) {
|
|
210
|
-
if (
|
|
255
|
+
if (!EMAIL.test(email)) {
|
|
211
256
|
return `${at} accessEmails has an invalid email '${email}'`;
|
|
212
257
|
}
|
|
213
258
|
}
|
package/dist/plan/boule.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export declare const BOULE_DATABASE_SCOPE_KEY = "coolify_boule_database";
|
|
|
29
29
|
*/
|
|
30
30
|
export declare function bouleHostnameFor(org: DeclaredOrg): string;
|
|
31
31
|
/** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
|
|
32
|
-
export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR"];
|
|
32
|
+
export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "ARCHON_URL"];
|
|
33
33
|
/**
|
|
34
34
|
* The env vars an org's Boule app should carry, and every input still missing
|
|
35
35
|
* to build them. Callers that write must refuse on a non-empty `missing`: a
|
|
@@ -41,11 +41,17 @@ export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER",
|
|
|
41
41
|
* id, never an email), present once `zitadel:owner` has recorded it. It is not
|
|
42
42
|
* `missing` while absent: Boule runs without an operator until then.
|
|
43
43
|
*
|
|
44
|
+
* `archonUrl` (#374) is the Archon that bills this org -- `https://` plus the
|
|
45
|
+
* declared platform's `archon.hostname` (`plan-apply.ts`'s `orgArchonUrl`),
|
|
46
|
+
* runtime only, read by Boule's Prices card (boule#228). Not `missing` while
|
|
47
|
+
* absent: Boule has no platform wired in every test and every org-only
|
|
48
|
+
* caller, same as `issuer` before #177.
|
|
49
|
+
*
|
|
44
50
|
* Not here, and written by the executor instead: the database URLs
|
|
45
51
|
* (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
|
|
46
52
|
* (generated once, never rotated).
|
|
47
53
|
*/
|
|
48
|
-
export declare function desiredBouleEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, issuer: string | null): {
|
|
54
|
+
export declare function desiredBouleEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, issuer: string | null, archonUrl: string | null): {
|
|
49
55
|
env: Record<string, string>;
|
|
50
56
|
missing: string[];
|
|
51
57
|
};
|
package/dist/plan/boule.js
CHANGED
|
@@ -37,6 +37,7 @@ export const BOULE_MANAGED_ENV_KEYS = [
|
|
|
37
37
|
'AUTH_CLIENT_ID',
|
|
38
38
|
'BOULE_ORG_NAME',
|
|
39
39
|
'AUTHZ_BOOTSTRAP_OPERATOR',
|
|
40
|
+
'ARCHON_URL',
|
|
40
41
|
];
|
|
41
42
|
/**
|
|
42
43
|
* The env vars an org's Boule app should carry, and every input still missing
|
|
@@ -49,11 +50,17 @@ export const BOULE_MANAGED_ENV_KEYS = [
|
|
|
49
50
|
* id, never an email), present once `zitadel:owner` has recorded it. It is not
|
|
50
51
|
* `missing` while absent: Boule runs without an operator until then.
|
|
51
52
|
*
|
|
53
|
+
* `archonUrl` (#374) is the Archon that bills this org -- `https://` plus the
|
|
54
|
+
* declared platform's `archon.hostname` (`plan-apply.ts`'s `orgArchonUrl`),
|
|
55
|
+
* runtime only, read by Boule's Prices card (boule#228). Not `missing` while
|
|
56
|
+
* absent: Boule has no platform wired in every test and every org-only
|
|
57
|
+
* caller, same as `issuer` before #177.
|
|
58
|
+
*
|
|
52
59
|
* Not here, and written by the executor instead: the database URLs
|
|
53
60
|
* (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
|
|
54
61
|
* (generated once, never rotated).
|
|
55
62
|
*/
|
|
56
|
-
export function desiredBouleEnv(org, recorded, issuer) {
|
|
63
|
+
export function desiredBouleEnv(org, recorded, issuer, archonUrl) {
|
|
57
64
|
const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
58
65
|
const clientId = recorded[BOULE_CLIENT_ID_SCOPE_KEY];
|
|
59
66
|
const missing = [];
|
|
@@ -73,6 +80,8 @@ export function desiredBouleEnv(org, recorded, issuer) {
|
|
|
73
80
|
env.AUTH_ORGANIZATION_ID = zitadelOrgId;
|
|
74
81
|
if (clientId)
|
|
75
82
|
env.AUTH_CLIENT_ID = clientId;
|
|
83
|
+
if (archonUrl)
|
|
84
|
+
env.ARCHON_URL = archonUrl;
|
|
76
85
|
const owner = org.owners[0];
|
|
77
86
|
const ownerUserId = owner
|
|
78
87
|
? recorded[`zitadel:owner:${org.id}:${owner.email.toLowerCase()}`]
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface PlanOrgOptions {
|
|
|
40
40
|
otherSlugs?: readonly string[];
|
|
41
41
|
/** F6a, F6b: the clock `planEntryAiKeys` and `planFilesKeys` read for the renewal window. Default `Date.now`. */
|
|
42
42
|
now?: () => number;
|
|
43
|
+
/** #374: the Archon that bills this org (`plan-apply.ts`'s `orgArchonUrl`), written into Boule's env as `ARCHON_URL`. `null` with no platform wired. */
|
|
44
|
+
archonUrl?: string | null;
|
|
43
45
|
}
|
|
44
46
|
export interface PlanDetail {
|
|
45
47
|
orgId: OrgId;
|
package/dist/plan/diff.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
2
|
import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
|
|
3
|
-
import { CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
3
|
+
import { CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
4
4
|
import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
5
5
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
6
6
|
import { planEntryAiKeys } from './entry-ai-key.js';
|
|
@@ -242,7 +242,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
242
242
|
const bouleClient = planBouleClient(org, byKey, claimed);
|
|
243
243
|
if (bouleClient)
|
|
244
244
|
plannedSteps.push(bouleClient);
|
|
245
|
-
const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? []);
|
|
245
|
+
const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null);
|
|
246
246
|
if (boule.step)
|
|
247
247
|
plannedSteps.push(boule.step);
|
|
248
248
|
// Boule's own broker entry, after its app: the entry needs the AUTH_APP_KEY
|
|
@@ -1003,6 +1003,15 @@ function notReadyReason(service, keyName, missingKeyRef, noKeyReader) {
|
|
|
1003
1003
|
* #177: blocked, not planned, while `filesReady` is false -- the platform's
|
|
1004
1004
|
* own files host (#171) is off or not yet applied, so there is nothing here
|
|
1005
1005
|
* for `FilesOrganisationPort.provisionOrganisation` to reach.
|
|
1006
|
+
*
|
|
1007
|
+
* #273: `desired.agoraOrigin` is every org's own Agora origin (#164's
|
|
1008
|
+
* `agoraHostnameFor`, always on, never blocked on anything this step
|
|
1009
|
+
* doesn't already wait for) -- `executor.ts`'s `filesOrganisation` adds it
|
|
1010
|
+
* to the files host's CORS allowlist alongside provisioning. Known gap: a
|
|
1011
|
+
* later domain change that moves Agora's hostname is not replanned here,
|
|
1012
|
+
* since this step (like `planIssuer`) has nothing to diff once the org's
|
|
1013
|
+
* Files organisation already exists -- the old origin is left allowed
|
|
1014
|
+
* rather than silently dropped.
|
|
1006
1015
|
*/
|
|
1007
1016
|
function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false) {
|
|
1008
1017
|
const key = resourceKey('files', 'organisation', org.id);
|
|
@@ -1023,7 +1032,12 @@ function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, n
|
|
|
1023
1032
|
system: 'files',
|
|
1024
1033
|
kind: 'organisation',
|
|
1025
1034
|
key: org.id,
|
|
1026
|
-
desired: {
|
|
1035
|
+
desired: {
|
|
1036
|
+
provision: true,
|
|
1037
|
+
ceilingBytes: null,
|
|
1038
|
+
ceilingMicros: null,
|
|
1039
|
+
agoraOrigin: `https://${agoraHostnameFor(org)}`,
|
|
1040
|
+
},
|
|
1027
1041
|
},
|
|
1028
1042
|
step: {
|
|
1029
1043
|
description: `provision ${org.slug}'s own organisation on Files`,
|
|
@@ -1498,7 +1512,7 @@ function planBouleClient(org, byKey, claimed) {
|
|
|
1498
1512
|
* address (the one Agora signs in at); without it the Boule is blocked, not
|
|
1499
1513
|
* planned, so every other step still applies.
|
|
1500
1514
|
*/
|
|
1501
|
-
function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs) {
|
|
1515
|
+
function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl) {
|
|
1502
1516
|
const key = resourceKey('coolify', 'boule', org.id);
|
|
1503
1517
|
const live = byKey.get(key);
|
|
1504
1518
|
if (live)
|
|
@@ -1532,7 +1546,7 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs) {
|
|
|
1532
1546
|
system: 'coolify',
|
|
1533
1547
|
kind: 'boule',
|
|
1534
1548
|
key: org.id,
|
|
1535
|
-
desired: { issuer },
|
|
1549
|
+
desired: { issuer, archonUrl },
|
|
1536
1550
|
};
|
|
1537
1551
|
if (!live) {
|
|
1538
1552
|
return {
|
|
@@ -1544,7 +1558,7 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs) {
|
|
|
1544
1558
|
};
|
|
1545
1559
|
}
|
|
1546
1560
|
const liveEnv = (live.config.env ?? {});
|
|
1547
|
-
const { env } = desiredBouleEnv(org, recordedIds, issuer);
|
|
1561
|
+
const { env } = desiredBouleEnv(org, recordedIds, issuer, archonUrl);
|
|
1548
1562
|
const drift = bouleEnvDrift(env, liveEnv);
|
|
1549
1563
|
if (drift.length === 0)
|
|
1550
1564
|
return { step: null, blocked: null };
|
|
@@ -2320,13 +2334,21 @@ function planCustomers(org, live, claimed, recordedIds) {
|
|
|
2320
2334
|
* each entry's `accessEmails` through. Idempotent on the recorded Access app id,
|
|
2321
2335
|
* like `planOwners`: Cloudflare's live listing has no Access apps. A later
|
|
2322
2336
|
* change to the email list is not reconciled yet.
|
|
2337
|
+
*
|
|
2338
|
+
* #246: widened to also plan one step per `hosting.access` rule -- a
|
|
2339
|
+
* `gate`, `bypass` or `service-token` Access policy on a hostname besides
|
|
2340
|
+
* the app's own (`share.<domain>`, `storage.<domain>`, ...), which the
|
|
2341
|
+
* `accessEmails` gate above has no way to express: it is email-only, and
|
|
2342
|
+
* only for the `app` entry. With no rules declared this plans exactly what
|
|
2343
|
+
* it planned before #246 -- no regression for an org that never set
|
|
2344
|
+
* `hosting.access`.
|
|
2323
2345
|
*/
|
|
2324
2346
|
function planAccess(org, recordedIds) {
|
|
2325
2347
|
if (!org.hosting.enabled)
|
|
2326
2348
|
return [];
|
|
2327
2349
|
// Only the `app` entry has a public name, and so only it can sit behind
|
|
2328
2350
|
// Access; per-entry hostnames are issue 309.
|
|
2329
|
-
|
|
2351
|
+
const appGate = org.hosting.entries
|
|
2330
2352
|
.filter((entry) => entry.key === APP_ENTRY_KEY && entry.accessEmails.length > 0)
|
|
2331
2353
|
.filter((entry) => !recordedIds[resourceKey('cloudflare', 'access', appStepKey(org, entry.key))])
|
|
2332
2354
|
.map((entry) => ({
|
|
@@ -2342,6 +2364,33 @@ function planAccess(org, recordedIds) {
|
|
|
2342
2364
|
migrates: false,
|
|
2343
2365
|
},
|
|
2344
2366
|
}));
|
|
2367
|
+
const ruleSteps = (org.hosting.access ?? [])
|
|
2368
|
+
.filter((rule) => !recordedIds[resourceKey('cloudflare', 'access', appStepKey(org, rule.hostname))])
|
|
2369
|
+
.map((rule) => ({
|
|
2370
|
+
action: {
|
|
2371
|
+
system: 'cloudflare',
|
|
2372
|
+
kind: 'access',
|
|
2373
|
+
key: appStepKey(org, rule.hostname),
|
|
2374
|
+
desired: {},
|
|
2375
|
+
},
|
|
2376
|
+
step: {
|
|
2377
|
+
description: accessRuleDescription(org, rule),
|
|
2378
|
+
destroys: false,
|
|
2379
|
+
migrates: false,
|
|
2380
|
+
},
|
|
2381
|
+
}));
|
|
2382
|
+
return [...appGate, ...ruleSteps];
|
|
2383
|
+
}
|
|
2384
|
+
/** The plan description for one `hosting.access` rule, by kind (#246). */
|
|
2385
|
+
function accessRuleDescription(org, rule) {
|
|
2386
|
+
switch (rule.kind) {
|
|
2387
|
+
case 'gate':
|
|
2388
|
+
return `gate ${org.slug}'s ${rule.hostname} behind Cloudflare Access`;
|
|
2389
|
+
case 'bypass':
|
|
2390
|
+
return `let ${rule.hostname} (${org.slug}) bypass Cloudflare Access`;
|
|
2391
|
+
case 'service-token':
|
|
2392
|
+
return `let only the '${rule.serviceTokenName}' service token through Cloudflare Access on ${rule.hostname} (${org.slug})`;
|
|
2393
|
+
}
|
|
2345
2394
|
}
|
|
2346
2395
|
/**
|
|
2347
2396
|
* https://github.com/wtfalch/foundry/issues/43: one Cloudflare rate-limiting
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -420,6 +420,15 @@ export interface CloudflarePort {
|
|
|
420
420
|
}): Promise<{
|
|
421
421
|
id: string;
|
|
422
422
|
}>;
|
|
423
|
+
/**
|
|
424
|
+
* `cloudflareAccess` resolves a `kind: 'service-token'` rule's named
|
|
425
|
+
* token to its id (#246) -- the token itself is a live Cloudflare
|
|
426
|
+
* resource this package never mints; `undefined` when no token by that
|
|
427
|
+
* name exists yet.
|
|
428
|
+
*/
|
|
429
|
+
findServiceTokenByName(name: string): Promise<{
|
|
430
|
+
id: string;
|
|
431
|
+
} | undefined>;
|
|
423
432
|
deleteAccessApp(appId: string): Promise<void>;
|
|
424
433
|
/**
|
|
425
434
|
* `FoundryPlanApply.destroy`'s teardown of any DNS record foundry created
|
|
@@ -1105,6 +1114,20 @@ export interface FilesOrganisationPort {
|
|
|
1105
1114
|
name: string;
|
|
1106
1115
|
created: boolean;
|
|
1107
1116
|
}>;
|
|
1117
|
+
/**
|
|
1118
|
+
* #273: adds `origin` to the files host's `FILES_ALLOWED_ORIGINS`
|
|
1119
|
+
* allowlist (`wtfalch/files#109`) -- additively, never removing another
|
|
1120
|
+
* organisation's entry already there. That list is a Coolify env var on
|
|
1121
|
+
* the files runner app, not a database row the files management API
|
|
1122
|
+
* reports back (files#109's own reasoning: a database round trip on the
|
|
1123
|
+
* unauthenticated CORS preflight is both a hot-path cost and a new way to
|
|
1124
|
+
* fail closed), so there is nothing here to diff against a live value --
|
|
1125
|
+
* the host decides how `origin` actually reaches that env var, the same
|
|
1126
|
+
* way every other method on this port decides how to speak the service's
|
|
1127
|
+
* protocol. Safely repeatable: calling it again with an origin already on
|
|
1128
|
+
* the list is a no-op.
|
|
1129
|
+
*/
|
|
1130
|
+
ensureAllowedOrigin(organisationId: string, origin: string): Promise<void>;
|
|
1108
1131
|
}
|
|
1109
1132
|
/**
|
|
1110
1133
|
* Provisions one org's organisation on AI and switches its AI use on (#80),
|
|
@@ -1394,15 +1417,30 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1394
1417
|
resourceId?: string;
|
|
1395
1418
|
} | undefined>;
|
|
1396
1419
|
/**
|
|
1397
|
-
* `plan/diff.ts`'s `planAccess` (#84): puts
|
|
1398
|
-
* Cloudflare Access app
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
1401
|
-
*
|
|
1402
|
-
*
|
|
1420
|
+
* `plan/diff.ts`'s `planAccess` (#84, widened by #246): puts a hostname
|
|
1421
|
+
* behind a Cloudflare Access app. `action.key` tells the `app` entry's own
|
|
1422
|
+
* gate (`accessEmails`, email-only) apart from a `hosting.access` rule on
|
|
1423
|
+
* any other hostname -- `appStepKey` builds both, keyed by the entry's key
|
|
1424
|
+
* or by the rule's hostname respectively, and the two never collide
|
|
1425
|
+
* because an entry key never holds a `.` while a rule's hostname always
|
|
1426
|
+
* does (`org.ts`'s `validateAccessRule`). The app-entry gate keeps the
|
|
1427
|
+
* Access app name `foundry:<orgId>` it has always used, so a re-run
|
|
1428
|
+
* against an org applied before #246 still finds the app it already
|
|
1429
|
+
* created; a rule's own app is named `foundry:<orgId>:<hostname>` instead,
|
|
1430
|
+
* since an org may declare more than one. Either way foundry reuses only
|
|
1431
|
+
* an app with its own name (so a re-run after a crash does not duplicate
|
|
1432
|
+
* it), refuses to adopt a hostname another Access app already holds
|
|
1433
|
+
* (destroy would later delete it), and refuses when its own app carries
|
|
1403
1434
|
* any policy but foundry's, since that policy could let others in.
|
|
1404
1435
|
*/
|
|
1405
1436
|
private cloudflareAccess;
|
|
1437
|
+
/** The Cloudflare Access policy body for one `hosting.access` rule, by kind (#246). */
|
|
1438
|
+
private accessPolicyFor;
|
|
1439
|
+
/**
|
|
1440
|
+
* The create-or-adopt-refusal logic `cloudflareAccess` shares between the
|
|
1441
|
+
* `app` entry's own gate and every `hosting.access` rule (#84, #246).
|
|
1442
|
+
*/
|
|
1443
|
+
private cloudflareAccessApp;
|
|
1406
1444
|
/**
|
|
1407
1445
|
* `plan/diff.ts`'s `planRateLimit` (#43): one Cloudflare rate limiting
|
|
1408
1446
|
* rule, scoped to `SIGN_IN_PATH_EXPRESSION`, on the org's own Cloudflare
|
|
@@ -2321,6 +2359,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2321
2359
|
* safely repeatable on its own (`FilesOrganisationPort`'s doc comment), so
|
|
2322
2360
|
* this is called every time regardless of whether it already ran --
|
|
2323
2361
|
* idempotent by find-or-create, not by checking `recordedIds` first.
|
|
2362
|
+
*
|
|
2363
|
+
* #273: also ensures the org's Agora origin is on the files host's CORS
|
|
2364
|
+
* allowlist (`FilesOrganisationPort.ensureAllowedOrigin`), the same
|
|
2365
|
+
* repeat-is-a-no-op shape as `provisionOrganisation` itself -- every org
|
|
2366
|
+
* gets an Agora (#164), so `diff.ts`'s `planFilesOrganisation` always sets
|
|
2367
|
+
* `desired.agoraOrigin`.
|
|
2324
2368
|
*/
|
|
2325
2369
|
private filesOrganisation;
|
|
2326
2370
|
/**
|