@wtfalch/foundry 0.45.1 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-json.d.ts +28 -0
- package/dist/build-json.js +95 -0
- package/dist/index.d.ts +10 -4
- package/dist/index.js +6 -2
- package/dist/migrations/0058_app_requests.sql +32 -0
- package/dist/org.d.ts +68 -5
- package/dist/org.js +287 -135
- package/dist/plan/agora.d.ts +14 -0
- package/dist/plan/agora.js +21 -0
- package/dist/plan/backup-gate.d.ts +15 -0
- package/dist/plan/backup-gate.js +38 -0
- package/dist/plan/boule-owners-http.d.ts +12 -0
- package/dist/plan/boule-owners-http.js +59 -0
- package/dist/plan/boule.d.ts +38 -2
- package/dist/plan/boule.js +72 -1
- package/dist/plan/company-backups.d.ts +7 -1
- package/dist/plan/company-backups.js +42 -2
- package/dist/plan/cookie-domain.d.ts +41 -0
- package/dist/plan/cookie-domain.js +71 -0
- package/dist/plan/desktop-auth.d.ts +58 -0
- package/dist/plan/desktop-auth.js +144 -0
- package/dist/plan/desktop-callback.d.ts +26 -0
- package/dist/plan/desktop-callback.js +59 -0
- package/dist/plan/diff.d.ts +13 -4
- package/dist/plan/diff.js +277 -88
- package/dist/plan/executor.d.ts +136 -6
- package/dist/plan/executor.js +732 -105
- package/dist/plan/health-gaps.js +5 -0
- package/dist/plan/health-targets.js +1 -2
- package/dist/plan/hostnames.d.ts +2 -2
- package/dist/plan/hostnames.js +6 -6
- package/dist/plan/index.d.ts +4 -1
- package/dist/plan/index.js +3 -1
- package/dist/plan/job-app.d.ts +23 -0
- package/dist/plan/job-app.js +41 -0
- package/dist/plan/live.d.ts +6 -0
- package/dist/plan/live.js +16 -0
- package/dist/plan/login-host.d.ts +28 -0
- package/dist/plan/login-host.js +36 -0
- package/dist/plan/org-names.js +9 -1
- package/dist/plan/org-postgres-job.d.ts +22 -0
- package/dist/plan/org-postgres-job.js +91 -0
- package/dist/plan/org-postgres-migrate.d.ts +115 -0
- package/dist/plan/org-postgres-migrate.js +466 -0
- package/dist/plan/org-postgres.d.ts +90 -0
- package/dist/plan/org-postgres.js +293 -0
- package/dist/plan/plan-apply.d.ts +60 -3
- package/dist/plan/plan-apply.js +174 -8
- package/dist/plan/platform-diff.d.ts +45 -0
- package/dist/plan/platform-diff.js +159 -4
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-org.js +4 -0
- package/dist/plan/platform-providers.d.ts +12 -2
- package/dist/plan/platform-providers.js +282 -9
- package/dist/plan/post-logout-uris.d.ts +22 -0
- package/dist/plan/post-logout-uris.js +43 -0
- package/dist/plan/providers-readers.d.ts +10 -1
- package/dist/plan/providers-readers.js +56 -5
- package/dist/plan/sign-in-bindings.d.ts +4 -0
- package/dist/plan/sign-in-bindings.js +10 -0
- package/dist/plan/sign-in-org.d.ts +19 -6
- package/dist/plan/sign-in-org.js +35 -10
- package/dist/plan/sign-in.d.ts +12 -5
- package/dist/plan/sign-in.js +12 -5
- package/dist/plan/signin-db-move.js +10 -34
- package/dist/plan/worker-app.js +3 -1
- package/dist/refuse-org.d.ts +7 -0
- package/dist/refuse-org.js +10 -0
- package/dist/requested-changes.d.ts +50 -4
- package/dist/requested-changes.js +9 -1
- package/dist/store/postgres.d.ts +5 -1
- package/dist/store/postgres.js +67 -8
- package/dist/store/schema.d.ts +72 -0
- package/dist/store/schema.js +4 -0
- package/dist/store.d.ts +24 -1
- package/dist/worker-app-config.d.ts +8 -2
- package/dist/worker-app-config.js +20 -6
- package/package.json +5 -5
|
@@ -2,6 +2,7 @@ import { ZitadelError } from '@wtfalch/providers/zitadel';
|
|
|
2
2
|
import { APP_ENTRY_KEY } from '../org.js';
|
|
3
3
|
import { BOULE_MAIL_TOKEN_ENV, BOULE_MANAGED_ENV_KEYS } from './boule.js';
|
|
4
4
|
import { previewsOn } from './coolify-previews.js';
|
|
5
|
+
import { APP_GONE_KIND, } from './live.js';
|
|
5
6
|
import { SIGN_IN_UPSTREAM_ENV } from './sign-in.js';
|
|
6
7
|
/**
|
|
7
8
|
* A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
|
|
@@ -279,12 +280,23 @@ async function withBouleEnvConfig(client, resource, recorded, liveKeys) {
|
|
|
279
280
|
// Boule must hold too. In memory only, compared by `planBoule` and never
|
|
280
281
|
// written to a step, description or log.
|
|
281
282
|
const orgId = recorded[resource.key]?.slice('boule:'.length);
|
|
282
|
-
|
|
283
|
+
// The token sits on the org's main app, whatever its key; the reader does not know the
|
|
284
|
+
// declaration, so it tries the `app` entry first and then each other recorded app.
|
|
285
|
+
const appPrefix = `app:${orgId}:`;
|
|
286
|
+
const appUuids = Object.entries(recorded)
|
|
287
|
+
.filter(([, value]) => value.startsWith(appPrefix))
|
|
288
|
+
.sort(([, a], [, b]) => Number(b === `${appPrefix}${APP_ENTRY_KEY}`) - Number(a === `${appPrefix}${APP_ENTRY_KEY}`))
|
|
289
|
+
.map(([uuid]) => uuid);
|
|
283
290
|
// A recorded app that is not in the live listing is gone: no app token, and
|
|
284
291
|
// no read of its envs. A listed app whose read fails fails like any read.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
292
|
+
let appToken;
|
|
293
|
+
for (const appUuid of appUuids) {
|
|
294
|
+
if (!liveKeys.has(appUuid))
|
|
295
|
+
continue;
|
|
296
|
+
appToken = (await client.listEnvs(appUuid)).find((e) => !e.is_preview && e.key === BOULE_MAIL_TOKEN_ENV)?.value;
|
|
297
|
+
if (appToken)
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
288
300
|
return {
|
|
289
301
|
...resource,
|
|
290
302
|
config: { ...resource.config, env, ...(appToken ? { appMailToken: appToken } : {}) },
|
|
@@ -416,7 +428,46 @@ export class ProvidersZitadelReader {
|
|
|
416
428
|
this.liveIssuer(orgId, zitadelOrgId),
|
|
417
429
|
]);
|
|
418
430
|
const mapped = applyRecordedIds(toFoundryResources('zitadel', resources), recorded);
|
|
419
|
-
|
|
431
|
+
const gone = await this.goneApps(zitadelOrgId, resources, recorded);
|
|
432
|
+
return issuer ? [...mapped, ...gone, issuer] : [...mapped, ...gone];
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* #557: a recorded app the listing does not return, proven gone by a direct
|
|
436
|
+
* read by app id in every project the listing shows. One `app-gone`
|
|
437
|
+
* resource per proven app, keyed by the app's name: `diff.ts` offers the
|
|
438
|
+
* replacement only for it. Nothing is proven when the client has no
|
|
439
|
+
* `getApp` (providers before 0.12.0), when the read throws, or when it
|
|
440
|
+
* finds the app, so the plan blocks instead.
|
|
441
|
+
*/
|
|
442
|
+
async goneApps(zitadelOrgId, listed, recorded) {
|
|
443
|
+
// A host may pass a client older than providers 0.12.0, which has no `getApp`.
|
|
444
|
+
const getApp = typeof this.client.getApp === 'function' ? this.client.getApp.bind(this.client) : undefined;
|
|
445
|
+
const listedIds = new Set(listed.filter((r) => r.kind === 'app').map((r) => r.key));
|
|
446
|
+
const projectIds = listed.filter((r) => r.kind === 'project').map((r) => r.key);
|
|
447
|
+
if (!getApp || projectIds.length === 0)
|
|
448
|
+
return [];
|
|
449
|
+
const gone = [];
|
|
450
|
+
for (const [appId, resourceKey] of Object.entries(recorded)) {
|
|
451
|
+
if (!resourceKey.startsWith('app:') || listedIds.has(appId))
|
|
452
|
+
continue;
|
|
453
|
+
try {
|
|
454
|
+
const found = await Promise.all(projectIds.map((projectId) => getApp(zitadelOrgId, projectId, appId)));
|
|
455
|
+
if (found.every((app) => app === null || app.state === 'APP_STATE_REMOVED')) {
|
|
456
|
+
gone.push({
|
|
457
|
+
system: 'zitadel',
|
|
458
|
+
kind: APP_GONE_KIND,
|
|
459
|
+
key: resourceKey.slice('app:'.length),
|
|
460
|
+
config: {},
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
// Unproven, not gone: the plan blocks this app. Only the error's name is kept.
|
|
466
|
+
if (!(error instanceof ZitadelError))
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return gone;
|
|
420
471
|
}
|
|
421
472
|
/**
|
|
422
473
|
* #510: `client.listResources` reports only what is *inside* a ZITADEL org
|
|
@@ -13,4 +13,8 @@ export declare function editSignInBindings(coolify: Pick<CoolifyPort, 'listEnvs'
|
|
|
13
13
|
instanceHost: string;
|
|
14
14
|
upsert?: readonly LoginHostBinding[];
|
|
15
15
|
remove?: readonly string[];
|
|
16
|
+
clientOrganizations?: {
|
|
17
|
+
upsert?: Readonly<Record<string, string>>;
|
|
18
|
+
remove?: readonly string[];
|
|
19
|
+
};
|
|
16
20
|
}): Promise<void>;
|
|
@@ -36,6 +36,16 @@ export async function editSignInBindings(coolify, input) {
|
|
|
36
36
|
...doc.bindings.filter((b) => !touched.has(b.host)),
|
|
37
37
|
...(input.upsert ?? []).map((b) => ({ ...b, clientIds: [...b.clientIds].sort() })),
|
|
38
38
|
].sort((a, b) => (a.host < b.host ? -1 : 1));
|
|
39
|
+
const orgs = input.clientOrganizations;
|
|
40
|
+
if (orgs && (Object.keys(orgs.upsert ?? {}).length > 0 || (orgs.remove ?? []).length > 0)) {
|
|
41
|
+
const merged = { ...doc.clientOrganizations, ...orgs.upsert };
|
|
42
|
+
const gone = new Set(orgs.remove ?? []);
|
|
43
|
+
const sorted = Object.fromEntries(Object.entries(merged)
|
|
44
|
+
.filter(([id]) => !gone.has(id))
|
|
45
|
+
.sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
46
|
+
// An empty map is left out of the document, as it was before the first write.
|
|
47
|
+
doc.clientOrganizations = Object.keys(sorted).length > 0 ? sorted : undefined;
|
|
48
|
+
}
|
|
39
49
|
const next = JSON.stringify(doc);
|
|
40
50
|
if (live?.value === next)
|
|
41
51
|
return;
|
|
@@ -7,6 +7,12 @@ export declare const ZITADEL_ORG_DOMAIN_SCOPE_KEY = "zitadel_org_domain";
|
|
|
7
7
|
* names another org, the company plan holds the close step and no owner invite.
|
|
8
8
|
*/
|
|
9
9
|
export declare const ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY = "zitadel_org_registration_closed";
|
|
10
|
+
/**
|
|
11
|
+
* Scope id: the id of the customers org whose self-registration foundry
|
|
12
|
+
* closed, recorded only after `closeSelfRegistration` returned. Foundry closes
|
|
13
|
+
* once and never opens: a company that turns sign-up on keeps it on.
|
|
14
|
+
*/
|
|
15
|
+
export declare const ZITADEL_CUSTOMERS_REGISTRATION_CLOSED_SCOPE_KEY = "zitadel_customers_registration_closed";
|
|
10
16
|
/** Closes self-registration on the org (idempotent) and records it under `scopeKey`, in that order. */
|
|
11
17
|
export declare function closeSignInOrgRegistration(zitadel: Pick<ZitadelPort, 'closeSelfRegistration'>, orgId: string, recordScopeId: (key: string, id: string) => void, scopeKey?: string): Promise<void>;
|
|
12
18
|
/** Scope id: the domain the sign-in server still refused to make primary after foundry set its domain policy and added the domain again. Set instead of failing; the planner reports it and plans the domain again. */
|
|
@@ -27,12 +33,17 @@ export declare function signInOrgDomain(org: {
|
|
|
27
33
|
slug: string;
|
|
28
34
|
domain: string | null;
|
|
29
35
|
}): string;
|
|
36
|
+
/** The fixed text for a domain the sign-in server already holds on another organisation. */
|
|
37
|
+
export declare function signInDomainTakenText(domain: string): string;
|
|
30
38
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
39
|
+
* Adds `domain` to the ZITADEL org, sets the server's domain policy
|
|
40
|
+
* ({@link SIGN_IN_DOMAIN_POLICY}) and makes the domain primary. Every call is
|
|
41
|
+
* idempotent. The add comes first, so foundry holds the domain before the
|
|
42
|
+
* policy stops asking for proof of ownership. A domain added while the policy
|
|
43
|
+
* still wanted proof stays unverified, so it is removed and added again (the
|
|
44
|
+
* remove-and-add gap: the domain is off the org for up to five seconds; known,
|
|
45
|
+
* left as is). A failure after the add leaves the domain on the org but not
|
|
46
|
+
* primary, and nothing is recorded, so the next run repeats.
|
|
36
47
|
*/
|
|
37
48
|
export declare function registerSignInDomain(zitadel: Pick<ZitadelPort, 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string, sleep?: (ms: number) => Promise<void>): Promise<void>;
|
|
38
49
|
/**
|
|
@@ -41,7 +52,7 @@ export declare function registerSignInDomain(zitadel: Pick<ZitadelPort, 'ensureD
|
|
|
41
52
|
* `blockedScopeKey` and returns `false` instead of throwing on every apply.
|
|
42
53
|
* Any other failure throws. Returns `true` when the domain is primary.
|
|
43
54
|
*/
|
|
44
|
-
export declare function registerSignInDomainOrBlock(zitadel: Pick<ZitadelPort, 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string, recordScopeId: (key: string, id: string) => void, blockedScopeKey?: string): Promise<boolean>;
|
|
55
|
+
export declare function registerSignInDomainOrBlock(zitadel: Pick<ZitadelPort, 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string, recordScopeId: (key: string, id: string) => void, blockedScopeKey?: string, sleep?: (ms: number) => Promise<void>): Promise<boolean>;
|
|
45
56
|
export interface EnsureSignInOrgOptions {
|
|
46
57
|
zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain' | 'closeSelfRegistration'>;
|
|
47
58
|
/** Where the successful close is recorded (the org id). Absent: not recorded; the caller closes on every run. */
|
|
@@ -60,6 +71,8 @@ export interface EnsureSignInOrgOptions {
|
|
|
60
71
|
orgScopeKey?: string;
|
|
61
72
|
/** Defaults to {@link ZITADEL_ORG_DOMAIN_SCOPE_KEY}. */
|
|
62
73
|
domainScopeKey?: string;
|
|
74
|
+
/** Waits between tries of the domain calls; a test passes a no-op. */
|
|
75
|
+
sleep?: (ms: number) => Promise<void>;
|
|
63
76
|
}
|
|
64
77
|
/**
|
|
65
78
|
* Finds or creates the company's sign-in organisation by name, registers
|
package/dist/plan/sign-in-org.js
CHANGED
|
@@ -6,6 +6,12 @@ export const ZITADEL_ORG_DOMAIN_SCOPE_KEY = 'zitadel_org_domain';
|
|
|
6
6
|
* names another org, the company plan holds the close step and no owner invite.
|
|
7
7
|
*/
|
|
8
8
|
export const ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY = 'zitadel_org_registration_closed';
|
|
9
|
+
/**
|
|
10
|
+
* Scope id: the id of the customers org whose self-registration foundry
|
|
11
|
+
* closed, recorded only after `closeSelfRegistration` returned. Foundry closes
|
|
12
|
+
* once and never opens: a company that turns sign-up on keeps it on.
|
|
13
|
+
*/
|
|
14
|
+
export const ZITADEL_CUSTOMERS_REGISTRATION_CLOSED_SCOPE_KEY = 'zitadel_customers_registration_closed';
|
|
9
15
|
/** Closes self-registration on the org (idempotent) and records it under `scopeKey`, in that order. */
|
|
10
16
|
export async function closeSignInOrgRegistration(zitadel, orgId, recordScopeId, scopeKey = ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY) {
|
|
11
17
|
await zitadel.closeSelfRegistration(orgId);
|
|
@@ -51,16 +57,35 @@ async function untilListed(work, retry, sleep) {
|
|
|
51
57
|
}
|
|
52
58
|
}
|
|
53
59
|
}
|
|
60
|
+
/** The fixed text for a domain the sign-in server already holds on another organisation. */
|
|
61
|
+
export function signInDomainTakenText(domain) {
|
|
62
|
+
return `the sign-in domain ${domain} already exists on another organisation of the sign-in server, so foundry cannot add it; remove it there and apply again`;
|
|
63
|
+
}
|
|
64
|
+
/** Adds the domain; an "already exists" answer becomes {@link signInDomainTakenText}, which holds no cause. */
|
|
65
|
+
async function addDomain(zitadel, orgId, domain) {
|
|
66
|
+
try {
|
|
67
|
+
await zitadel.addOrgDomain(orgId, domain);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof Error && /already[ .]?exists/i.test(error.message)) {
|
|
71
|
+
throw new Error(signInDomainTakenText(domain));
|
|
72
|
+
}
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
54
76
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
77
|
+
* Adds `domain` to the ZITADEL org, sets the server's domain policy
|
|
78
|
+
* ({@link SIGN_IN_DOMAIN_POLICY}) and makes the domain primary. Every call is
|
|
79
|
+
* idempotent. The add comes first, so foundry holds the domain before the
|
|
80
|
+
* policy stops asking for proof of ownership. A domain added while the policy
|
|
81
|
+
* still wanted proof stays unverified, so it is removed and added again (the
|
|
82
|
+
* remove-and-add gap: the domain is off the org for up to five seconds; known,
|
|
83
|
+
* left as is). A failure after the add leaves the domain on the org but not
|
|
84
|
+
* primary, and nothing is recorded, so the next run repeats.
|
|
60
85
|
*/
|
|
61
86
|
export async function registerSignInDomain(zitadel, orgId, domain, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
|
87
|
+
await addDomain(zitadel, orgId, domain);
|
|
62
88
|
await zitadel.ensureDomainPolicy(SIGN_IN_DOMAIN_POLICY);
|
|
63
|
-
await zitadel.addOrgDomain(orgId, domain);
|
|
64
89
|
// The sign-in server lists a domain a moment after it accepts the add, and
|
|
65
90
|
// `setPrimaryOrgDomain` reads that list first: ask again, for five seconds.
|
|
66
91
|
try {
|
|
@@ -75,7 +100,7 @@ export async function registerSignInDomain(zitadel, orgId, domain, sleep = (ms)
|
|
|
75
100
|
// Until the server lists the removal, the add sees the old unverified
|
|
76
101
|
// domain and skips: both answers are a moment late, so ask again.
|
|
77
102
|
await untilListed(async () => {
|
|
78
|
-
await zitadel
|
|
103
|
+
await addDomain(zitadel, orgId, domain);
|
|
79
104
|
await zitadel.setPrimaryOrgDomain(orgId, domain);
|
|
80
105
|
}, (error) => isDomainNotListed(error) || isPrimaryDomainRefused(error), sleep);
|
|
81
106
|
}
|
|
@@ -85,9 +110,9 @@ export async function registerSignInDomain(zitadel, orgId, domain, sleep = (ms)
|
|
|
85
110
|
* `blockedScopeKey` and returns `false` instead of throwing on every apply.
|
|
86
111
|
* Any other failure throws. Returns `true` when the domain is primary.
|
|
87
112
|
*/
|
|
88
|
-
export async function registerSignInDomainOrBlock(zitadel, orgId, domain, recordScopeId, blockedScopeKey = ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY) {
|
|
113
|
+
export async function registerSignInDomainOrBlock(zitadel, orgId, domain, recordScopeId, blockedScopeKey = ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, sleep) {
|
|
89
114
|
try {
|
|
90
|
-
await registerSignInDomain(zitadel, orgId, domain);
|
|
115
|
+
await registerSignInDomain(zitadel, orgId, domain, sleep);
|
|
91
116
|
return true;
|
|
92
117
|
}
|
|
93
118
|
catch (error) {
|
|
@@ -115,7 +140,7 @@ export async function ensureSignInOrg(options) {
|
|
|
115
140
|
if (options.registrationClosedScopeKey) {
|
|
116
141
|
recordScopeId(options.registrationClosedScopeKey, org.id);
|
|
117
142
|
}
|
|
118
|
-
const registered = await registerSignInDomainOrBlock(zitadel, org.id, domain, recordScopeId, options.domainBlockedScopeKey);
|
|
143
|
+
const registered = await registerSignInDomainOrBlock(zitadel, org.id, domain, recordScopeId, options.domainBlockedScopeKey, options.sleep);
|
|
119
144
|
if (!registered)
|
|
120
145
|
return { orgId: org.id, created, domainBlocked: true };
|
|
121
146
|
recordScopeId(options.domainScopeKey ?? ZITADEL_ORG_DOMAIN_SCOPE_KEY, domain);
|
package/dist/plan/sign-in.d.ts
CHANGED
|
@@ -31,10 +31,13 @@ export declare const SIGN_IN_LOGIN_APP_SCOPE_KEY = "coolify_platform_auth_login_
|
|
|
31
31
|
* 1. on: the login-host step trusts each app host ({@link SIGN_IN_TRUSTED_SCOPE_KEY}).
|
|
32
32
|
* 2. on: the app step writes and deploys `SIGNIN_UPSTREAM` and records it live ({@link SIGN_IN_ENV_LIVE_SCOPE_PREFIX}).
|
|
33
33
|
* 3. on: the login-host step binds each app host and points the base URI at `https://<app host>/sign-in/`
|
|
34
|
-
* ({@link SIGN_IN_IN_APP_SCOPE_KEY}).
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
34
|
+
* ({@link SIGN_IN_IN_APP_SCOPE_KEY}). From auth 0.28.0 (one client may sit in two bindings of one company)
|
|
35
|
+
* this is three writes: the app host's binding with the client id kept on the company's, the base URI, then
|
|
36
|
+
* the company's binding without the id; sign-in works at every point. Before that release the login refuses
|
|
37
|
+
* a client id held by two bindings, so one write moves the id off the company's binding: that write and the
|
|
38
|
+
* base URI are the only moment the old path can fail, and they are the last two calls.
|
|
39
|
+
* Off: the login-host step moves the client id back and points the base URI back at the login host (0.28.0 and
|
|
40
|
+
* later: the company binding takes the id first and the app binding goes after the base URI); the app
|
|
38
41
|
* step then drops `SIGNIN_UPSTREAM`; the login-host step then gives up the trusted domain.
|
|
39
42
|
*/
|
|
40
43
|
/**
|
|
@@ -70,7 +73,11 @@ export declare const SIGN_IN_RATE_LIMIT_EXPRESSION = "(starts_with(http.request.
|
|
|
70
73
|
* today's flow even when the switch is set (the declaration is refused: `org.ts`'s `validateSignIn`).
|
|
71
74
|
*/
|
|
72
75
|
export declare function inAppSignIn(org: Pick<DeclaredOrg, 'signIn' | 'domain' | 'issuer' | 'services'>): boolean;
|
|
73
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* True when an app's live env holds `SIGNIN_UPSTREAM` and the desired env does not: it is to be removed.
|
|
78
|
+
* Accepted behaviour: a `SIGNIN_UPSTREAM` added by hand on Agora or Boule is drift like any other env foundry
|
|
79
|
+
* owns, so the next apply removes it. Foundry writes the value itself (`signInUpstream`) while the switch is on.
|
|
80
|
+
*/
|
|
74
81
|
export declare function staleSignInUpstream(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): boolean;
|
|
75
82
|
/** The build argument (a build-time env in Coolify) that fixes the login's base path. */
|
|
76
83
|
export declare const SIGN_IN_BUILD_ARG_ENV = "NEXT_PUBLIC_BASE_PATH";
|
package/dist/plan/sign-in.js
CHANGED
|
@@ -31,10 +31,13 @@ export const SIGN_IN_LOGIN_APP_SCOPE_KEY = 'coolify_platform_auth_login_sign_in_
|
|
|
31
31
|
* 1. on: the login-host step trusts each app host ({@link SIGN_IN_TRUSTED_SCOPE_KEY}).
|
|
32
32
|
* 2. on: the app step writes and deploys `SIGNIN_UPSTREAM` and records it live ({@link SIGN_IN_ENV_LIVE_SCOPE_PREFIX}).
|
|
33
33
|
* 3. on: the login-host step binds each app host and points the base URI at `https://<app host>/sign-in/`
|
|
34
|
-
* ({@link SIGN_IN_IN_APP_SCOPE_KEY}).
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
34
|
+
* ({@link SIGN_IN_IN_APP_SCOPE_KEY}). From auth 0.28.0 (one client may sit in two bindings of one company)
|
|
35
|
+
* this is three writes: the app host's binding with the client id kept on the company's, the base URI, then
|
|
36
|
+
* the company's binding without the id; sign-in works at every point. Before that release the login refuses
|
|
37
|
+
* a client id held by two bindings, so one write moves the id off the company's binding: that write and the
|
|
38
|
+
* base URI are the only moment the old path can fail, and they are the last two calls.
|
|
39
|
+
* Off: the login-host step moves the client id back and points the base URI back at the login host (0.28.0 and
|
|
40
|
+
* later: the company binding takes the id first and the app binding goes after the base URI); the app
|
|
38
41
|
* step then drops `SIGNIN_UPSTREAM`; the login-host step then gives up the trusted domain.
|
|
39
42
|
*/
|
|
40
43
|
/**
|
|
@@ -76,7 +79,11 @@ export const SIGN_IN_RATE_LIMIT_EXPRESSION = '(starts_with(http.request.uri.path
|
|
|
76
79
|
export function inAppSignIn(org) {
|
|
77
80
|
return org.signIn?.inApp === true && Boolean(org.domain) && org.issuer.usesDomain;
|
|
78
81
|
}
|
|
79
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* True when an app's live env holds `SIGNIN_UPSTREAM` and the desired env does not: it is to be removed.
|
|
84
|
+
* Accepted behaviour: a `SIGNIN_UPSTREAM` added by hand on Agora or Boule is drift like any other env foundry
|
|
85
|
+
* owns, so the next apply removes it. Foundry writes the value itself (`signInUpstream`) while the switch is on.
|
|
86
|
+
*/
|
|
80
87
|
export function staleSignInUpstream(desired, live) {
|
|
81
88
|
const held = live[SIGN_IN_UPSTREAM_ENV];
|
|
82
89
|
return desired[SIGN_IN_UPSTREAM_ENV] === undefined && held !== undefined && held !== '';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { pinnedRef } from '../bootstrap/versions.js';
|
|
2
|
+
import { JobFailure, waitForJob as waitForJobApp } from './job-app.js';
|
|
2
3
|
import { PLATFORM_AUTH_DB_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_SCOPE_KEY, findOrCreatePlatformApp, } from './platform-providers.js';
|
|
3
4
|
import { SIGNIN_DB_TLS_SCOPE_KEY, SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY } from './signin-db-tls.js';
|
|
4
5
|
import { SIGNIN_DB_NAME, SIGNIN_DB_PORT, SIGNIN_DB_USER, boundedCall, dockerfileAppCreator, ensureSigninDbCaFile, provisionSigninDb, waitHealthy, withCallTimeout, } from './signin-db.js';
|
|
@@ -94,41 +95,16 @@ export function signinDbMoveNewUrl(host, password, database) {
|
|
|
94
95
|
class MoveFailure extends Error {
|
|
95
96
|
}
|
|
96
97
|
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
97
|
-
/**
|
|
98
|
-
const JOB_EXITED_POLLS = 6;
|
|
99
|
-
/**
|
|
100
|
-
* Waits for the copy job to report healthy, which it does only after the counts match. The job never
|
|
101
|
-
* exits on success, so any exit is final: `restarting` at once, `exited` once the job was seen running
|
|
102
|
-
* or stayed exited for {@link JOB_EXITED_POLLS} polls. It has failed too at the deadline. A healthy
|
|
103
|
-
* status read before any other could be the container before the stop, so it counts only after a
|
|
104
|
-
* different status was seen.
|
|
105
|
-
*/
|
|
98
|
+
/** Waits for the copy job to report healthy, which it does only after the counts match. */
|
|
106
99
|
async function waitForJob(coolify, appId, runtime) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
for (;;) {
|
|
116
|
-
const { status } = await coolify.getApplication(appId);
|
|
117
|
-
if (status === 'running:healthy' && left)
|
|
118
|
-
return;
|
|
119
|
-
if (status !== 'running:healthy')
|
|
120
|
-
left = true;
|
|
121
|
-
if (status?.startsWith('running'))
|
|
122
|
-
running = true;
|
|
123
|
-
exitedPolls = status?.startsWith('exited') ? exitedPolls + 1 : 0;
|
|
124
|
-
if (status?.startsWith('restarting') ||
|
|
125
|
-
(status?.startsWith('exited') && (running || exitedPolls >= JOB_EXITED_POLLS))) {
|
|
126
|
-
throw new MoveFailure('the copy job stopped before the row counts matched');
|
|
127
|
-
}
|
|
128
|
-
if (now() >= deadline) {
|
|
129
|
-
throw new MoveFailure(`the copy job did not finish within ${Math.round(timeout / 1000)}s (last status: ${status ?? 'unknown'})`);
|
|
130
|
-
}
|
|
131
|
-
await sleep(interval);
|
|
100
|
+
try {
|
|
101
|
+
await waitForJobApp(coolify, appId, runtime, {
|
|
102
|
+
job: 'the copy job',
|
|
103
|
+
stopped: 'the copy job stopped before the row counts matched',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw error instanceof JobFailure ? new MoveFailure(error.message) : error;
|
|
132
108
|
}
|
|
133
109
|
}
|
|
134
110
|
/** Coolify queues a stop. Waits until it reports the app stopped; `pg_dump` must not run while ZITADEL can still write. */
|
package/dist/plan/worker-app.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
-
import { WORKER_APP_HYPERDRIVE_BINDING, WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHostId, } from '../worker-app-config.js';
|
|
2
|
+
import { WORKER_APP_HYPERDRIVE_BINDING, WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHasBuild, workerAppHostId, } from '../worker-app-config.js';
|
|
3
3
|
import { entryAuthEnv } from './broker-entries.js';
|
|
4
4
|
import { workerAppHostname } from './hostnames.js';
|
|
5
5
|
import { appStepKey } from './platform-diff.js';
|
|
@@ -315,6 +315,8 @@ export function planWorkerApps(org, recordedIds, input) {
|
|
|
315
315
|
description: `${at} is blocked, not planned: ${why}`,
|
|
316
316
|
key,
|
|
317
317
|
});
|
|
318
|
+
if (!workerAppHasBuild(app))
|
|
319
|
+
continue;
|
|
318
320
|
if (!input.available) {
|
|
319
321
|
block('the host supplies no WorkerAppPort');
|
|
320
322
|
continue;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type DeclaredOrg, type OrgRefusal } from './org.js';
|
|
2
|
+
/**
|
|
3
|
+
* The first refusal of the whole-company validation a store write runs for
|
|
4
|
+
* hosting (every entry, its sign-in included) and customers, or `null`. Locked
|
|
5
|
+
* names are the org's own; the platform's service names are not known here.
|
|
6
|
+
*/
|
|
7
|
+
export declare function refuseOrg(org: DeclaredOrg): OrgRefusal | null;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { refuseCustomers, refuseHosting } from './org.js';
|
|
2
|
+
import { hostingNames } from './plan/locked-hostnames.js';
|
|
3
|
+
/**
|
|
4
|
+
* The first refusal of the whole-company validation a store write runs for
|
|
5
|
+
* hosting (every entry, its sign-in included) and customers, or `null`. Locked
|
|
6
|
+
* names are the org's own; the platform's service names are not known here.
|
|
7
|
+
*/
|
|
8
|
+
export function refuseOrg(org) {
|
|
9
|
+
return refuseHosting(org.hosting, hostingNames(org, [])) ?? refuseCustomers(org);
|
|
10
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { BuildJson } from './build-json.js';
|
|
2
|
+
import { type CustomersConfig, type DeclaredOrg, type HostingEntry, type MailMode, type OrgId } from './org.js';
|
|
2
3
|
import type { PlanDetail } from './plan/diff.js';
|
|
3
4
|
/**
|
|
4
5
|
* The whitelist of `DeclaredOrg` fields a company's own Boule can request a
|
|
@@ -15,7 +16,7 @@ import type { PlanDetail } from './plan/diff.js';
|
|
|
15
16
|
* lowering or leaving a limit unchanged -- raising either one needs
|
|
16
17
|
* approval too, until a billing field exists to say what the raise costs.
|
|
17
18
|
*/
|
|
18
|
-
export type RequestableField = 'calls' | 'customers' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier';
|
|
19
|
+
export type RequestableField = 'calls' | 'customers' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier' | 'app' | 'build';
|
|
19
20
|
export declare const REQUESTABLE_FIELDS: readonly RequestableField[];
|
|
20
21
|
/** Turn the org's own calls Matrix stack (`org.ts`'s `CallsConfig.enabled`) on or off. */
|
|
21
22
|
export interface CallsChange {
|
|
@@ -52,12 +53,34 @@ export interface MailTierChange {
|
|
|
52
53
|
field: 'mailTier';
|
|
53
54
|
mode: MailMode;
|
|
54
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Put or remove one hosting entry of the company's own app (`org.ts`'s `HostingEntry`).
|
|
58
|
+
* `before` and `after` are the entry as the requester saw it and as it should be (null when absent).
|
|
59
|
+
* `customers` is the switches after the change when the request changes them, else null.
|
|
60
|
+
* Archon validates the whole company; {@link classifyChange} does not look at the org for this kind.
|
|
61
|
+
*/
|
|
62
|
+
export interface AppChange {
|
|
63
|
+
field: 'app';
|
|
64
|
+
key: string;
|
|
65
|
+
op: 'put' | 'remove';
|
|
66
|
+
before: HostingEntry | null;
|
|
67
|
+
after: HostingEntry | null;
|
|
68
|
+
customers: CustomersConfig | null;
|
|
69
|
+
}
|
|
70
|
+
/** Deploy one release of the company's app from its `build.json` (`build-json.ts`). */
|
|
71
|
+
export interface BuildChange {
|
|
72
|
+
field: 'build';
|
|
73
|
+
key: string;
|
|
74
|
+
repo: string;
|
|
75
|
+
release: string;
|
|
76
|
+
build: BuildJson;
|
|
77
|
+
}
|
|
55
78
|
/**
|
|
56
79
|
* One company-requested change to its own declared org, keyed by
|
|
57
80
|
* {@link RequestableField}. `classifyChange` and the store both switch on
|
|
58
81
|
* `.field`.
|
|
59
82
|
*/
|
|
60
|
-
export type RequestedChangeValue = CallsChange | CustomersChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange;
|
|
83
|
+
export type RequestedChangeValue = CallsChange | CustomersChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange | AppChange | BuildChange;
|
|
61
84
|
/**
|
|
62
85
|
* `auto`: nothing further is required -- archon may carry the request
|
|
63
86
|
* straight into a plan and apply it. `approval`: a human must approve the
|
|
@@ -72,7 +95,21 @@ export type ChangeClassification = 'auto' | 'approval';
|
|
|
72
95
|
* onward. Foundry only stores and classifies (#178 "Why"); nothing in this
|
|
73
96
|
* package transitions a state past its initial value.
|
|
74
97
|
*/
|
|
75
|
-
export type RequestedChangeState = 'auto' | 'needs_approval' | 'approved' | 'rejected' | 'applied';
|
|
98
|
+
export type RequestedChangeState = 'auto' | 'needs_approval' | 'approved' | 'rejected' | 'applied' | 'applying' | 'failed';
|
|
99
|
+
/** Why an `app` or `build` request is `failed`. */
|
|
100
|
+
export type FailureCode = 'replaced' | 'refused' | 'busy' | 'timed_out' | 'apply_error';
|
|
101
|
+
/** Who asked: a person in Boule, or a release build. `requestedBy` keeps the raw principal id for audit. */
|
|
102
|
+
export type RequestedBy = {
|
|
103
|
+
via: 'boule';
|
|
104
|
+
person: {
|
|
105
|
+
id: string;
|
|
106
|
+
name: string;
|
|
107
|
+
};
|
|
108
|
+
} | {
|
|
109
|
+
via: 'build';
|
|
110
|
+
repo: string;
|
|
111
|
+
release: string;
|
|
112
|
+
};
|
|
76
113
|
/**
|
|
77
114
|
* One company-requested change to its own declared org, as stored --
|
|
78
115
|
* `migrations/0017_requested_changes.sql`'s `foundry_requested_changes`
|
|
@@ -85,6 +122,15 @@ export interface RequestedChange {
|
|
|
85
122
|
requestedBy: string;
|
|
86
123
|
createdAt: string;
|
|
87
124
|
state: RequestedChangeState;
|
|
125
|
+
/** Null for rows made before `app` and `build` requests. */
|
|
126
|
+
requester: RequestedBy | null;
|
|
127
|
+
idempotencyKey: string | null;
|
|
128
|
+
reason: {
|
|
129
|
+
code: FailureCode;
|
|
130
|
+
message: string;
|
|
131
|
+
replacedBy?: number;
|
|
132
|
+
} | null;
|
|
133
|
+
updatedAt: string;
|
|
88
134
|
}
|
|
89
135
|
/**
|
|
90
136
|
* Thrown by {@link classifyChange} (and by the requested-change store's
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validateCustomers } from './org.js';
|
|
1
|
+
import { validateCustomers, } from './org.js';
|
|
2
2
|
export const REQUESTABLE_FIELDS = [
|
|
3
3
|
'calls',
|
|
4
4
|
'customers',
|
|
@@ -6,6 +6,8 @@ export const REQUESTABLE_FIELDS = [
|
|
|
6
6
|
'aiLimit',
|
|
7
7
|
'domain',
|
|
8
8
|
'mailTier',
|
|
9
|
+
'app',
|
|
10
|
+
'build',
|
|
9
11
|
];
|
|
10
12
|
/**
|
|
11
13
|
* Thrown by {@link classifyChange} (and by the requested-change store's
|
|
@@ -42,6 +44,9 @@ function applyRequestedChange(org, change) {
|
|
|
42
44
|
return { ...org, domain: change.domain };
|
|
43
45
|
case 'mailTier':
|
|
44
46
|
return { ...org, mail: { ...org.mail, mode: change.mode } };
|
|
47
|
+
case 'app':
|
|
48
|
+
case 'build':
|
|
49
|
+
return org;
|
|
45
50
|
}
|
|
46
51
|
}
|
|
47
52
|
/**
|
|
@@ -115,6 +120,9 @@ export function classifyChange(org, change, plan) {
|
|
|
115
120
|
if (!REQUESTABLE_FIELDS.includes(change.field)) {
|
|
116
121
|
throw new RequestedChangeError(`'${change.field}' is not a requestable field`);
|
|
117
122
|
}
|
|
123
|
+
// Archon validates the whole company for these; nothing waits for approval.
|
|
124
|
+
if (change.field === 'app' || change.field === 'build')
|
|
125
|
+
return 'auto';
|
|
118
126
|
if (change.field === 'customers') {
|
|
119
127
|
validateCustomersChange(change);
|
|
120
128
|
// The org after the change must still pass the rule apply checks, for example open registration
|
package/dist/store/postgres.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
|
|
2
|
+
import type { OrgRefusal, OrgRefusalCode } from '../org.js';
|
|
2
3
|
import { type ReleaseStore } from '../release/state.js';
|
|
3
4
|
import { type AppliedVersionsStore, type DeclaredOrgStore, type PlatformStore, type RequestedChangeStore } from '../store.js';
|
|
4
5
|
/**
|
|
@@ -38,7 +39,10 @@ export declare function uniqueViolationConstraint(error: unknown): string | unde
|
|
|
38
39
|
* is that throw.
|
|
39
40
|
*/
|
|
40
41
|
export declare class DeclaredOrgError extends Error {
|
|
41
|
-
|
|
42
|
+
/** Set when a hosting or customers validator refused the write: which rule, and where in the org. */
|
|
43
|
+
readonly code?: OrgRefusalCode;
|
|
44
|
+
readonly path?: string;
|
|
45
|
+
constructor(message: string, refusal?: Pick<OrgRefusal, 'code' | 'path'>);
|
|
42
46
|
}
|
|
43
47
|
/**
|
|
44
48
|
* `DeclaredOrgStore` (`../store.ts`) on Postgres, for #18: create, read and
|