@wtfalch/foundry 0.15.0 → 0.16.1
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/declare.d.ts +2 -2
- package/dist/bootstrap/declare.js +6 -6
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/migrations/0034_owners.sql +42 -0
- package/dist/migrations/0035_chat_on_by_default.sql +10 -0
- package/dist/org.d.ts +80 -17
- package/dist/org.js +221 -18
- package/dist/plan/agora.d.ts +19 -1
- package/dist/plan/agora.js +37 -2
- package/dist/plan/chat-matrix.d.ts +99 -0
- package/dist/plan/chat-matrix.js +194 -0
- package/dist/plan/diff.d.ts +5 -3
- package/dist/plan/diff.js +143 -83
- package/dist/plan/entry-ai-key.d.ts +58 -0
- package/dist/plan/entry-ai-key.js +209 -0
- package/dist/plan/entry-files-key.d.ts +82 -0
- package/dist/plan/entry-files-key.js +327 -0
- package/dist/plan/executor.d.ts +244 -16
- package/dist/plan/executor.js +866 -84
- package/dist/plan/hostnames.d.ts +14 -1
- package/dist/plan/hostnames.js +26 -1
- package/dist/plan/index.d.ts +1 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/plan-apply.d.ts +2 -0
- package/dist/plan/plan-apply.js +42 -2
- package/dist/plan/platform-org.d.ts +17 -5
- package/dist/plan/platform-org.js +15 -7
- package/dist/plan/platform-providers.js +4 -3
- package/dist/plan/proxy-labels.d.ts +42 -0
- package/dist/plan/proxy-labels.js +73 -0
- package/dist/plan/zitadel-machine-user.d.ts +5 -1
- package/dist/plan/zitadel-machine-user.js +5 -1
- package/dist/plan/zitadel-project-roles.d.ts +25 -0
- package/dist/plan/zitadel-project-roles.js +104 -0
- package/dist/store/postgres.js +4 -17
- package/dist/store/schema.d.ts +13 -45
- package/dist/store/schema.js +5 -8
- package/package.json +1 -1
package/dist/plan/hostnames.d.ts
CHANGED
|
@@ -6,18 +6,31 @@ import type { DeclaredOrg, HostingEntry } from '../org.js';
|
|
|
6
6
|
* acted on elsewhere.
|
|
7
7
|
*/
|
|
8
8
|
export declare function entryHostnames(org: DeclaredOrg, entry: HostingEntry): string[];
|
|
9
|
+
/**
|
|
10
|
+
* The wildcard name (`*.<domain>`) when an entry declares `'*'`, else `null`.
|
|
11
|
+
* Like `entryHostnames`, it is read only while hosting is on, `usesDomain` is
|
|
12
|
+
* on and the org has a domain.
|
|
13
|
+
*/
|
|
14
|
+
export declare function wildcardHostname(org: DeclaredOrg): string | null;
|
|
9
15
|
/**
|
|
10
16
|
* The one primary name of the org's app: its first, or the name the app was
|
|
11
17
|
* served at before entries had hostnames. `APP_URL`, the ZITADEL redirect URI
|
|
12
18
|
* and the jobs registration read it.
|
|
13
19
|
*/
|
|
14
20
|
export declare function appHostname(org: DeclaredOrg): string;
|
|
21
|
+
/**
|
|
22
|
+
* Coolify's `domains` field: comma-separated full URLs. A bare hostname is a
|
|
23
|
+
* 422 ("Invalid URL"), so every write names each host as `https://<host>`.
|
|
24
|
+
* Foundry's own records and proxy labels keep the bare names.
|
|
25
|
+
*/
|
|
26
|
+
export declare function coolifyDomains(names: readonly string[]): string;
|
|
15
27
|
/** What `domains` held on an `app` entry's Coolify app before entries had hostnames. */
|
|
16
28
|
export declare function legacyAppHostname(org: DeclaredOrg): string;
|
|
17
29
|
/**
|
|
18
30
|
* The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
|
|
19
31
|
* #164): a site's two names, every entry's names, an own-domain calls name and
|
|
20
|
-
* Agora's. An org that does not host keeps the single
|
|
32
|
+
* Agora's, then the wildcard last. An org that does not host keeps the single
|
|
33
|
+
* name it always had.
|
|
21
34
|
*/
|
|
22
35
|
export declare function tunnelIngressHostnames(org: DeclaredOrg): string[];
|
|
23
36
|
/** The ingress names that get a DNS record: those under the org's own domain. */
|
package/dist/plan/hostnames.js
CHANGED
|
@@ -13,6 +13,17 @@ export function entryHostnames(org, entry) {
|
|
|
13
13
|
}
|
|
14
14
|
return entry.hostnames.flatMap((name) => name === '*' ? [] : name === '@' ? [domain] : [`${name}.${domain}`]);
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* The wildcard name (`*.<domain>`) when an entry declares `'*'`, else `null`.
|
|
18
|
+
* Like `entryHostnames`, it is read only while hosting is on, `usesDomain` is
|
|
19
|
+
* on and the org has a domain.
|
|
20
|
+
*/
|
|
21
|
+
export function wildcardHostname(org) {
|
|
22
|
+
const { domain } = org;
|
|
23
|
+
if (!org.hosting.enabled || !org.hosting.usesDomain || !domain)
|
|
24
|
+
return null;
|
|
25
|
+
return org.hosting.entries.some((entry) => entry.hostnames.includes('*')) ? `*.${domain}` : null;
|
|
26
|
+
}
|
|
16
27
|
/**
|
|
17
28
|
* The one primary name of the org's app: its first, or the name the app was
|
|
18
29
|
* served at before entries had hostnames. `APP_URL`, the ZITADEL redirect URI
|
|
@@ -23,6 +34,14 @@ export function appHostname(org) {
|
|
|
23
34
|
const first = entry ? entryHostnames(org, entry)[0] : undefined;
|
|
24
35
|
return first ?? legacyAppHostname(org);
|
|
25
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Coolify's `domains` field: comma-separated full URLs. A bare hostname is a
|
|
39
|
+
* 422 ("Invalid URL"), so every write names each host as `https://<host>`.
|
|
40
|
+
* Foundry's own records and proxy labels keep the bare names.
|
|
41
|
+
*/
|
|
42
|
+
export function coolifyDomains(names) {
|
|
43
|
+
return names.map((name) => `https://${name}`).join(',');
|
|
44
|
+
}
|
|
26
45
|
/** What `domains` held on an `app` entry's Coolify app before entries had hostnames. */
|
|
27
46
|
export function legacyAppHostname(org) {
|
|
28
47
|
return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
|
|
@@ -30,7 +49,8 @@ export function legacyAppHostname(org) {
|
|
|
30
49
|
/**
|
|
31
50
|
* The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
|
|
32
51
|
* #164): a site's two names, every entry's names, an own-domain calls name and
|
|
33
|
-
* Agora's. An org that does not host keeps the single
|
|
52
|
+
* Agora's, then the wildcard last. An org that does not host keeps the single
|
|
53
|
+
* name it always had.
|
|
34
54
|
*/
|
|
35
55
|
export function tunnelIngressHostnames(org) {
|
|
36
56
|
const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [];
|
|
@@ -48,6 +68,11 @@ export function tunnelIngressHostnames(org) {
|
|
|
48
68
|
if (org.domain) {
|
|
49
69
|
hostnames.push(agoraHostnameFor(org));
|
|
50
70
|
}
|
|
71
|
+
// The wildcard goes last: Cloudflare takes the first ingress rule that
|
|
72
|
+
// matches, so every exact name above must come before `*.<domain>`.
|
|
73
|
+
const wildcard = wildcardHostname(org);
|
|
74
|
+
if (wildcard)
|
|
75
|
+
hostnames.push(wildcard);
|
|
51
76
|
return [...new Set(hostnames)];
|
|
52
77
|
}
|
|
53
78
|
/** The ingress names that get a DNS record: those under the org's own domain. */
|
package/dist/plan/index.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEME
|
|
|
17
17
|
export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
|
|
18
18
|
export type { AiManagementKeyMinter, PlatformAiDeps, PlatformAiRuntime, } from './platform-providers.js';
|
|
19
19
|
export { createZitadelAdminMinter } from './zitadel-machine-user.js';
|
|
20
|
+
export { createZitadelProjectRoles } from './zitadel-project-roles.js';
|
|
20
21
|
export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './platform-sfu.js';
|
|
21
22
|
export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
22
23
|
export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './mail-copy.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, Provide
|
|
|
9
9
|
export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
|
|
10
10
|
export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
|
|
11
11
|
export { createZitadelAdminMinter } from './zitadel-machine-user.js';
|
|
12
|
+
export { createZitadelProjectRoles } from './zitadel-project-roles.js';
|
|
12
13
|
export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
13
14
|
export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
14
15
|
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
|
|
@@ -120,6 +120,8 @@ export interface StepExecutionContext {
|
|
|
120
120
|
* run, plus whatever a previous apply already persisted.
|
|
121
121
|
*/
|
|
122
122
|
recordedIds(): Readonly<Record<string, string>>;
|
|
123
|
+
/** The clock the plan was made with, in ms; the same one `computePlanDetail` read. Absent: `Date.now()`. */
|
|
124
|
+
now?(): number;
|
|
123
125
|
/**
|
|
124
126
|
* Records a provider *scope* id — `coolify_project`, `zitadel_org`,
|
|
125
127
|
* `zitadel_project`, `cloudflare_zone` — alongside whatever `system:kind:key`
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -4,6 +4,8 @@ import { validateHosting } from '../org.js';
|
|
|
4
4
|
import { PLATFORM_SERVICE_KEYS } from '../platform.js';
|
|
5
5
|
import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
|
|
6
6
|
import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
|
|
7
|
+
import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
|
|
8
|
+
import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
|
|
7
9
|
import { readLivePlatformResources, readLiveResources, } from './live.js';
|
|
8
10
|
import { lockedHostnames } from './locked-hostnames.js';
|
|
9
11
|
import { planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
|
|
@@ -292,6 +294,7 @@ export class FoundryPlanApply {
|
|
|
292
294
|
const context = {
|
|
293
295
|
org,
|
|
294
296
|
recordedIds: () => ({ ...serviceIds }),
|
|
297
|
+
now: () => this.now().getTime(),
|
|
295
298
|
recordScopeId: (key, id) => {
|
|
296
299
|
serviceIds[key] = id;
|
|
297
300
|
},
|
|
@@ -368,7 +371,11 @@ export class FoundryPlanApply {
|
|
|
368
371
|
this.orgPlatformReadiness(),
|
|
369
372
|
this.orgAgoraHost(id),
|
|
370
373
|
]);
|
|
371
|
-
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
|
|
374
|
+
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
|
|
375
|
+
loginHostBindings: this.loginHostBindings,
|
|
376
|
+
broker: this.broker,
|
|
377
|
+
now: () => this.now().getTime(),
|
|
378
|
+
});
|
|
372
379
|
const invalid = await this.invalidHosting(org);
|
|
373
380
|
if (invalid) {
|
|
374
381
|
// Rule 2: reported in `unmanaged`, not thrown. Steps that read the
|
|
@@ -599,12 +606,20 @@ function destroyTargets(serviceIds) {
|
|
|
599
606
|
'coolify:database:',
|
|
600
607
|
'coolify:app:',
|
|
601
608
|
'zitadel:customers:',
|
|
609
|
+
// F6a: an entry's ai key, `<orgId>:<entry>`; the org's own key has no `:` and is left alone.
|
|
610
|
+
'ai:key:',
|
|
611
|
+
// F6b: an entry's files key, `<orgId>:<entry>`; the org's own key has no `:` and is left alone.
|
|
612
|
+
'files:key:',
|
|
602
613
|
];
|
|
603
614
|
const targets = [];
|
|
604
615
|
for (const prefix of ORDERED_PREFIXES) {
|
|
605
616
|
for (const [resourceKey, id] of Object.entries(serviceIds)) {
|
|
606
617
|
if (!resourceKey.startsWith(prefix))
|
|
607
618
|
continue;
|
|
619
|
+
if (prefix === 'ai:key:' && !resourceKey.slice(prefix.length).includes(':'))
|
|
620
|
+
continue;
|
|
621
|
+
if (prefix === 'files:key:' && !resourceKey.slice(prefix.length).includes(':'))
|
|
622
|
+
continue;
|
|
608
623
|
const [system, kind] = prefix.split(':');
|
|
609
624
|
targets.push({ system, kind, key: resourceKey.slice(prefix.length), id });
|
|
610
625
|
}
|
|
@@ -642,7 +657,7 @@ function destroyServiceIdKey(target) {
|
|
|
642
657
|
*/
|
|
643
658
|
const STEP_FIELDS = {
|
|
644
659
|
'zitadel:issuer': 'issuer.usesDomain',
|
|
645
|
-
'zitadel:owner': '
|
|
660
|
+
'zitadel:owner': 'owners',
|
|
646
661
|
'zitadel:customers': 'hosting.entries.uses.auth',
|
|
647
662
|
'coolify:broker-entry': 'hosting.entries.uses.auth',
|
|
648
663
|
'coolify:mail': 'mail',
|
|
@@ -661,6 +676,13 @@ const STEP_FIELDS = {
|
|
|
661
676
|
'cloudflare:ratelimit': 'hosting.usesDomain',
|
|
662
677
|
};
|
|
663
678
|
function stepField(action) {
|
|
679
|
+
// An entry's ai key (F6a) is keyed `<orgId>:<entry>`; the org's own is `<orgId>`.
|
|
680
|
+
if (action.system === 'ai' && action.kind === 'key' && action.key.includes(':')) {
|
|
681
|
+
return 'hosting.entries.uses.ai';
|
|
682
|
+
}
|
|
683
|
+
// The org's own files key (F6b) is keyed `<orgId>`, an entry's `<orgId>:<entry>`.
|
|
684
|
+
if (action.system === 'files' && action.kind === 'key')
|
|
685
|
+
return 'hosting.entries.uses.files';
|
|
664
686
|
// An org declares several issuer apps, each keyed by its name (#71).
|
|
665
687
|
if (action.system === 'zitadel' && action.kind === 'app') {
|
|
666
688
|
return `hosting.issuerApps.${action.key}`;
|
|
@@ -691,6 +713,24 @@ function failedFields(org, detail, completed, serviceIds) {
|
|
|
691
713
|
// as converged with no working key.
|
|
692
714
|
if (serviceIds[AI_KEY_UNDELIVERED_SCOPE_KEY] && !serviceIds[`ai:key:${org.id}`])
|
|
693
715
|
failed.add('ai');
|
|
716
|
+
// The same for an entry's ai key (F6a): minted, never delivered, so no step is planned.
|
|
717
|
+
for (const entry of org.hosting.entries) {
|
|
718
|
+
if (entry.uses?.ai && serviceIds[entryAiUndeliveredScopeKey(entry.key)]) {
|
|
719
|
+
failed.add('hosting.entries.uses.ai');
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
// The same for the org's files key and an entry's (F6b): minted, never delivered, so no step is planned.
|
|
723
|
+
if (org.hosting.entries.some((e) => e.uses?.files)) {
|
|
724
|
+
if (serviceIds[FILES_ROOT_UNDELIVERED_SCOPE_KEY] &&
|
|
725
|
+
serviceIds[FILES_ROOT_UNDELIVERED_SCOPE_KEY] !== serviceIds[`files:key:${org.id}`]) {
|
|
726
|
+
failed.add('hosting.entries.uses.files');
|
|
727
|
+
}
|
|
728
|
+
for (const entry of org.hosting.entries) {
|
|
729
|
+
if (entry.uses?.files && serviceIds[entryFilesUndeliveredScopeKey(entry.key)]) {
|
|
730
|
+
failed.add('hosting.entries.uses.files');
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
694
734
|
// The same for a jobs registration whose credentials never reached the host.
|
|
695
735
|
if (serviceIds[JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY] &&
|
|
696
736
|
!serviceIds[`jobs:registration:${org.id}`]) {
|
|
@@ -32,8 +32,18 @@ export interface OrgPlatformReadiness {
|
|
|
32
32
|
} | null;
|
|
33
33
|
/** The files host (#171). Files is always on for every org (#80), so `false` always blocks rather than only mattering when a flag is set. */
|
|
34
34
|
filesReady: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* The files host's internal address, which an entry with `uses.files` gets
|
|
37
|
+
* as `FILES_URL`. `null` or absent until the platform has recorded it.
|
|
38
|
+
*/
|
|
39
|
+
filesUrl?: string | null;
|
|
35
40
|
/** The AI host (#172), the same always-on shape as {@link filesReady}. */
|
|
36
41
|
aiReady: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* The AI host's internal address, which an entry with `uses.ai` gets as
|
|
44
|
+
* `AI_URL`. `null` or absent until the platform has recorded it.
|
|
45
|
+
*/
|
|
46
|
+
aiUrl?: string | null;
|
|
37
47
|
/**
|
|
38
48
|
* The shared calls SFU (#170) an org's own calls Matrix stack depends on.
|
|
39
49
|
* Declared is enough here: unlike the others, `platform-diff.ts`'s
|
|
@@ -110,11 +120,13 @@ noKeyReader?: boolean): OrgPlatformReadiness;
|
|
|
110
120
|
* than requiring a bespoke host-supplied `AgoraHostPort` for values the
|
|
111
121
|
* platform already knows -- the `issuer`/`mailUpstream`/`filesUpstream`
|
|
112
122
|
* addresses `agora.ts`'s own doc comment named this issue as the source of.
|
|
113
|
-
* `mailProjectId
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
123
|
+
* `mailProjectId` is the `mail` ZITADEL project `provisionPlatformMail` finds
|
|
124
|
+
* or creates and records under `PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY`; it
|
|
125
|
+
* is read once mail is enabled and applied (the same gate as `mailUpstream`),
|
|
126
|
+
* and null before that. `messagingProjectId` is not read from the
|
|
127
|
+
* platform: `agora.ts`'s `withRecordedMessagingProject` fills it from
|
|
128
|
+
* the project each org's chat step creates -- this only stops
|
|
129
|
+
* pretending the other values are unavailable once the platform actually
|
|
118
130
|
* supplies them. `plan-apply.ts`'s `FoundryPlanApply` calls this only when no
|
|
119
131
|
* host-supplied `agora` port overrides it.
|
|
120
132
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, platformMailMxHost, } from './platform-diff.js';
|
|
1
|
+
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
|
|
2
2
|
import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './platform-providers.js';
|
|
3
3
|
/**
|
|
4
4
|
* What an org's plan reads when no platform is declared: nothing is ready, so
|
|
@@ -76,7 +76,11 @@ noKeyReader = false) {
|
|
|
76
76
|
? { apex: platform.apex, mailHost: platformMailMxHost(platform.apex) }
|
|
77
77
|
: null,
|
|
78
78
|
filesReady: filesApplied && missingKeys.files === null,
|
|
79
|
+
filesUrl: filesApplied
|
|
80
|
+
? (platformRecordedIds[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] ?? null)
|
|
81
|
+
: null,
|
|
79
82
|
aiReady: aiApplied && missingKeys.ai === null,
|
|
83
|
+
aiUrl: aiApplied ? (platformRecordedIds[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY] ?? null) : null,
|
|
80
84
|
callsReady: platform.sfu.enabled,
|
|
81
85
|
authReady: platform.auth.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_AUTH_KEY),
|
|
82
86
|
jobsReady: jobsApplied && missingKeys.jobs === null,
|
|
@@ -89,11 +93,13 @@ noKeyReader = false) {
|
|
|
89
93
|
* than requiring a bespoke host-supplied `AgoraHostPort` for values the
|
|
90
94
|
* platform already knows -- the `issuer`/`mailUpstream`/`filesUpstream`
|
|
91
95
|
* addresses `agora.ts`'s own doc comment named this issue as the source of.
|
|
92
|
-
* `mailProjectId
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
96
|
+
* `mailProjectId` is the `mail` ZITADEL project `provisionPlatformMail` finds
|
|
97
|
+
* or creates and records under `PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY`; it
|
|
98
|
+
* is read once mail is enabled and applied (the same gate as `mailUpstream`),
|
|
99
|
+
* and null before that. `messagingProjectId` is not read from the
|
|
100
|
+
* platform: `agora.ts`'s `withRecordedMessagingProject` fills it from
|
|
101
|
+
* the project each org's chat step creates -- this only stops
|
|
102
|
+
* pretending the other values are unavailable once the platform actually
|
|
97
103
|
* supplies them. `plan-apply.ts`'s `FoundryPlanApply` calls this only when no
|
|
98
104
|
* host-supplied `agora` port overrides it.
|
|
99
105
|
*/
|
|
@@ -103,7 +109,9 @@ export function agoraHostValuesFromPlatform(platform, platformRecordedIds) {
|
|
|
103
109
|
const filesReady = platform.files.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_FILES_KEY);
|
|
104
110
|
return {
|
|
105
111
|
issuer: authReady ? `https://${platform.auth.hostname}` : null,
|
|
106
|
-
mailProjectId:
|
|
112
|
+
mailProjectId: mailReady
|
|
113
|
+
? (platformRecordedIds[PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY] ?? null)
|
|
114
|
+
: null,
|
|
107
115
|
messagingProjectId: null,
|
|
108
116
|
mailUpstream: mailReady
|
|
109
117
|
? (platformRecordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY] ?? null)
|
|
@@ -5,6 +5,7 @@ import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
|
5
5
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
6
6
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
7
7
|
import { ACCESS_POLICY_NAME, isNotFoundError, isRunningOrStarting } from './executor.js';
|
|
8
|
+
import { coolifyDomains } from './hostnames.js';
|
|
8
9
|
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
|
|
9
10
|
import { applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
10
11
|
import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
|
|
@@ -424,7 +425,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
424
425
|
git_branch: gitRef,
|
|
425
426
|
build_pack: 'dockerfile',
|
|
426
427
|
name: JOBS_SCHEDULER_KEY,
|
|
427
|
-
domains: hostname,
|
|
428
|
+
domains: coolifyDomains([hostname]),
|
|
428
429
|
});
|
|
429
430
|
appId = created.uuid;
|
|
430
431
|
}
|
|
@@ -4369,7 +4370,7 @@ async function provisionPlatformIntegrations(action, context, deps) {
|
|
|
4369
4370
|
};
|
|
4370
4371
|
const migrateApp = await findOrCreatePlatformApp(coolify, INTEGRATIONS_MIGRATE_APP.name, environment, createApp(INTEGRATIONS_MIGRATE_APP.name));
|
|
4371
4372
|
await record(INTEGRATIONS_MIGRATE_APP_SCOPE_KEY, migrateApp.appId);
|
|
4372
|
-
const runnerApp = await findOrCreatePlatformApp(coolify, INTEGRATIONS_RUNNER_APP.name, environment, createApp(INTEGRATIONS_RUNNER_APP.name, hostname));
|
|
4373
|
+
const runnerApp = await findOrCreatePlatformApp(coolify, INTEGRATIONS_RUNNER_APP.name, environment, createApp(INTEGRATIONS_RUNNER_APP.name, coolifyDomains([hostname])));
|
|
4373
4374
|
await record(INTEGRATIONS_RUNNER_APP_SCOPE_KEY, runnerApp.appId);
|
|
4374
4375
|
const workerApp = await findOrCreatePlatformApp(coolify, INTEGRATIONS_WORKER_APP.name, environment, createApp(INTEGRATIONS_WORKER_APP.name));
|
|
4375
4376
|
await record(INTEGRATIONS_WORKER_APP_SCOPE_KEY, workerApp.appId);
|
|
@@ -4387,7 +4388,7 @@ async function provisionPlatformIntegrations(action, context, deps) {
|
|
|
4387
4388
|
dockerfile_target_build: INTEGRATIONS_RUNNER_APP.target,
|
|
4388
4389
|
ports_exposes: INTEGRATIONS_RUNNER_PORT,
|
|
4389
4390
|
git_branch: gitRef,
|
|
4390
|
-
domains: hostname,
|
|
4391
|
+
domains: coolifyDomains([hostname]),
|
|
4391
4392
|
});
|
|
4392
4393
|
await coolify.updateApplication(workerApp.appId, {
|
|
4393
4394
|
dockerfile_target_build: INTEGRATIONS_WORKER_APP.target,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { DeclaredOrg, HostingEntry } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* What an entry's Traefik label set is built from. `wildcardDomain` is the
|
|
4
|
+
* org's domain when the entry declares `'*'`, else `null`.
|
|
5
|
+
*/
|
|
6
|
+
export interface EntryLabelSpec {
|
|
7
|
+
names: string[];
|
|
8
|
+
wildcardDomain: string | null;
|
|
9
|
+
port: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The scope key holding the hash of the label set last patched onto an
|
|
13
|
+
* entry's app. Present only for an entry that has had a custom label set.
|
|
14
|
+
*/
|
|
15
|
+
export declare function appLabelsAppliedScopeKey(entryKey: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* The label set an entry's app needs, or `null` when it needs none. Coolify
|
|
18
|
+
* replaces the labels it generates from `domains` once `custom_labels` is
|
|
19
|
+
* set, so an entry with `'*'` carries its exact `Host` rules as well. An entry
|
|
20
|
+
* that already has a custom set (`hadLabels`) keeps one, without the wildcard
|
|
21
|
+
* rule, so a removed `'*'` stops matching and a later name change still
|
|
22
|
+
* reaches the app: an empty `custom_labels` is refused by Coolify's API.
|
|
23
|
+
* Coolify's default port for an app is 80.
|
|
24
|
+
*/
|
|
25
|
+
export declare function entryLabelSpec(org: DeclaredOrg, entry: HostingEntry, hadLabels: boolean): EntryLabelSpec | null;
|
|
26
|
+
/** A short digest of the spec; the same spec always gives the same hash. */
|
|
27
|
+
export declare function entryLabelHash(spec: EntryLabelSpec): string;
|
|
28
|
+
/**
|
|
29
|
+
* The Traefik labels for an entry's app, one per line. Coolify reaches an app
|
|
30
|
+
* through its http entrypoint here (the tunnel dials `coolify-proxy:80`), so
|
|
31
|
+
* each name gets the same http router Coolify generates for a plain domain:
|
|
32
|
+
* `Host(name) && PathPrefix(/)`, default priority. The wildcard is one
|
|
33
|
+
* `HostRegexp` router at `priority=1`, so any exact name, this app's or
|
|
34
|
+
* another's, wins over it. The regex matches one label (`[a-z0-9-]+`), the
|
|
35
|
+
* same depth Cloudflare's Universal certificate covers.
|
|
36
|
+
*/
|
|
37
|
+
export declare function entryProxyLabels(appUuid: string, spec: EntryLabelSpec): string;
|
|
38
|
+
/**
|
|
39
|
+
* Whether two label sets hold the same labels. Order and the `coolify.*`
|
|
40
|
+
* lines Coolify adds and strips itself do not count.
|
|
41
|
+
*/
|
|
42
|
+
export declare function sameLabelSets(a: string, b: string): boolean;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { entryHostnames, wildcardHostname } from './hostnames.js';
|
|
3
|
+
/**
|
|
4
|
+
* The scope key holding the hash of the label set last patched onto an
|
|
5
|
+
* entry's app. Present only for an entry that has had a custom label set.
|
|
6
|
+
*/
|
|
7
|
+
export function appLabelsAppliedScopeKey(entryKey) {
|
|
8
|
+
return `app_labels_applied.${entryKey}`;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The label set an entry's app needs, or `null` when it needs none. Coolify
|
|
12
|
+
* replaces the labels it generates from `domains` once `custom_labels` is
|
|
13
|
+
* set, so an entry with `'*'` carries its exact `Host` rules as well. An entry
|
|
14
|
+
* that already has a custom set (`hadLabels`) keeps one, without the wildcard
|
|
15
|
+
* rule, so a removed `'*'` stops matching and a later name change still
|
|
16
|
+
* reaches the app: an empty `custom_labels` is refused by Coolify's API.
|
|
17
|
+
* Coolify's default port for an app is 80.
|
|
18
|
+
*/
|
|
19
|
+
export function entryLabelSpec(org, entry, hadLabels) {
|
|
20
|
+
const wildcard = entry.hostnames.includes('*') ? wildcardHostname(org) : null;
|
|
21
|
+
if (!wildcard && !hadLabels)
|
|
22
|
+
return null;
|
|
23
|
+
return {
|
|
24
|
+
names: entryHostnames(org, entry),
|
|
25
|
+
wildcardDomain: wildcard ? wildcard.slice(2) : null,
|
|
26
|
+
port: entry.buildPack === 'static' ? 80 : (entry.port ?? 80),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** A short digest of the spec; the same spec always gives the same hash. */
|
|
30
|
+
export function entryLabelHash(spec) {
|
|
31
|
+
return createHash('sha256').update(JSON.stringify(spec)).digest('hex').slice(0, 16);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The Traefik labels for an entry's app, one per line. Coolify reaches an app
|
|
35
|
+
* through its http entrypoint here (the tunnel dials `coolify-proxy:80`), so
|
|
36
|
+
* each name gets the same http router Coolify generates for a plain domain:
|
|
37
|
+
* `Host(name) && PathPrefix(/)`, default priority. The wildcard is one
|
|
38
|
+
* `HostRegexp` router at `priority=1`, so any exact name, this app's or
|
|
39
|
+
* another's, wins over it. The regex matches one label (`[a-z0-9-]+`), the
|
|
40
|
+
* same depth Cloudflare's Universal certificate covers.
|
|
41
|
+
*/
|
|
42
|
+
export function entryProxyLabels(appUuid, spec) {
|
|
43
|
+
const labels = [];
|
|
44
|
+
const route = (id, rule, priority) => {
|
|
45
|
+
const router = `traefik.http.routers.${id}`;
|
|
46
|
+
labels.push(`${router}.rule=${rule}`, `${router}.entryPoints=http`, `${router}.service=${id}`, `${router}.middlewares=gzip`, `traefik.http.services.${id}.loadbalancer.server.port=${spec.port}`);
|
|
47
|
+
if (priority !== undefined)
|
|
48
|
+
labels.push(`${router}.priority=${priority}`);
|
|
49
|
+
};
|
|
50
|
+
spec.names.forEach((name, i) => {
|
|
51
|
+
route(`http-${i}-${appUuid}`, `Host(\`${name}\`) && PathPrefix(\`/\`)`);
|
|
52
|
+
});
|
|
53
|
+
if (spec.wildcardDomain) {
|
|
54
|
+
const domain = spec.wildcardDomain.replace(/\./g, '[.]');
|
|
55
|
+
route(`http-wildcard-${appUuid}`, `HostRegexp(\`^[a-z0-9-]+[.]${domain}$\`)`, 1);
|
|
56
|
+
}
|
|
57
|
+
if (labels.length === 0)
|
|
58
|
+
return 'traefik.enable=false';
|
|
59
|
+
labels.push('traefik.enable=true', 'traefik.http.middlewares.gzip.compress=true');
|
|
60
|
+
return labels.sort().join('\n');
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Whether two label sets hold the same labels. Order and the `coolify.*`
|
|
64
|
+
* lines Coolify adds and strips itself do not count.
|
|
65
|
+
*/
|
|
66
|
+
export function sameLabelSets(a, b) {
|
|
67
|
+
const lines = (set) => set
|
|
68
|
+
.split(/\r\n|\n|\r/)
|
|
69
|
+
.filter((line) => line !== '' && !line.startsWith('coolify.'))
|
|
70
|
+
.sort()
|
|
71
|
+
.join('\n');
|
|
72
|
+
return lines(a) === lines(b);
|
|
73
|
+
}
|
|
@@ -49,7 +49,11 @@ import type { ZitadelPort } from './executor.js';
|
|
|
49
49
|
*/
|
|
50
50
|
export declare class ZitadelHttpError extends Error {
|
|
51
51
|
readonly status: number;
|
|
52
|
-
|
|
52
|
+
/** ZITADEL's own gRPC-style error code from the body (6 is AlreadyExists), when it sent one. */
|
|
53
|
+
readonly code?: number | undefined;
|
|
54
|
+
constructor(status: number, message: string,
|
|
55
|
+
/** ZITADEL's own gRPC-style error code from the body (6 is AlreadyExists), when it sent one. */
|
|
56
|
+
code?: number | undefined);
|
|
53
57
|
}
|
|
54
58
|
/**
|
|
55
59
|
* #216: thrown by {@link createZitadelAdminMinter}'s `mintLoginClientMachineUser`
|
|
@@ -48,9 +48,13 @@
|
|
|
48
48
|
*/
|
|
49
49
|
export class ZitadelHttpError extends Error {
|
|
50
50
|
status;
|
|
51
|
-
|
|
51
|
+
code;
|
|
52
|
+
constructor(status, message,
|
|
53
|
+
/** ZITADEL's own gRPC-style error code from the body (6 is AlreadyExists), when it sent one. */
|
|
54
|
+
code) {
|
|
52
55
|
super(message);
|
|
53
56
|
this.status = status;
|
|
57
|
+
this.code = code;
|
|
54
58
|
this.name = 'ZitadelHttpError';
|
|
55
59
|
}
|
|
56
60
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ZitadelPort } from './executor.js';
|
|
2
|
+
export interface ZitadelProjectRolesOptions {
|
|
3
|
+
/** The ZITADEL instance's base URL. */
|
|
4
|
+
url: string;
|
|
5
|
+
/** Bearer token allowed to manage projects and user grants. */
|
|
6
|
+
token: string;
|
|
7
|
+
fetchImpl?: typeof fetch;
|
|
8
|
+
instanceHost?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The real {@link ZitadelPort.ensureProjectRole} and
|
|
12
|
+
* {@link ZitadelPort.grantProjectRoles}. `@wtfalch/providers` has no
|
|
13
|
+
* project-role API, so this speaks ZITADEL's v1 management API directly, with
|
|
14
|
+
* the request shape `createZitadelAdminMinter` uses. The calls follow
|
|
15
|
+
* ZITADEL's published API reference and have not been run against a live
|
|
16
|
+
* instance.
|
|
17
|
+
*
|
|
18
|
+
* 1. `POST /management/v1/projects/{id}/roles` adds the role; a 409 means it
|
|
19
|
+
* exists already.
|
|
20
|
+
* 2. `POST /management/v1/users/grants/_search` finds the user's grant on the
|
|
21
|
+
* project; `PUT /management/v1/users/{id}/grants/{grantId}` adds the
|
|
22
|
+
* missing roles to it, or `POST /management/v1/users/{id}/grants` creates
|
|
23
|
+
* the grant.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createZitadelProjectRoles(options: ZitadelProjectRolesOptions): Pick<ZitadelPort, 'ensureProjectRole' | 'grantProjectRoles'>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { ZitadelHttpError } from './zitadel-machine-user.js';
|
|
2
|
+
/**
|
|
3
|
+
* The real {@link ZitadelPort.ensureProjectRole} and
|
|
4
|
+
* {@link ZitadelPort.grantProjectRoles}. `@wtfalch/providers` has no
|
|
5
|
+
* project-role API, so this speaks ZITADEL's v1 management API directly, with
|
|
6
|
+
* the request shape `createZitadelAdminMinter` uses. The calls follow
|
|
7
|
+
* ZITADEL's published API reference and have not been run against a live
|
|
8
|
+
* instance.
|
|
9
|
+
*
|
|
10
|
+
* 1. `POST /management/v1/projects/{id}/roles` adds the role; a 409 means it
|
|
11
|
+
* exists already.
|
|
12
|
+
* 2. `POST /management/v1/users/grants/_search` finds the user's grant on the
|
|
13
|
+
* project; `PUT /management/v1/users/{id}/grants/{grantId}` adds the
|
|
14
|
+
* missing roles to it, or `POST /management/v1/users/{id}/grants` creates
|
|
15
|
+
* the grant.
|
|
16
|
+
*/
|
|
17
|
+
export function createZitadelProjectRoles(options) {
|
|
18
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
19
|
+
async function call(method, path, orgId, body) {
|
|
20
|
+
const response = await fetchImpl(`${options.url}${path}`, {
|
|
21
|
+
method,
|
|
22
|
+
headers: {
|
|
23
|
+
authorization: `Bearer ${options.token}`,
|
|
24
|
+
'content-type': 'application/json',
|
|
25
|
+
'x-zitadel-orgid': orgId,
|
|
26
|
+
...(options.instanceHost
|
|
27
|
+
? {
|
|
28
|
+
'x-zitadel-instance-host': options.instanceHost,
|
|
29
|
+
'x-forwarded-host': options.instanceHost,
|
|
30
|
+
'x-forwarded-proto': 'https',
|
|
31
|
+
}
|
|
32
|
+
: {}),
|
|
33
|
+
},
|
|
34
|
+
body: JSON.stringify(body),
|
|
35
|
+
});
|
|
36
|
+
const text = await response.text();
|
|
37
|
+
let data = {};
|
|
38
|
+
try {
|
|
39
|
+
data = text ? JSON.parse(text) : {};
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new ZitadelHttpError(response.status, `non-JSON response from ${path}`);
|
|
43
|
+
}
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw new ZitadelHttpError(response.status, String(data.message ?? response.statusText), typeof data.code === 'number' ? data.code : undefined);
|
|
46
|
+
}
|
|
47
|
+
return data;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
async ensureProjectRole(orgId, projectId, role) {
|
|
51
|
+
try {
|
|
52
|
+
await call('POST', `/management/v1/projects/${projectId}/roles`, orgId, {
|
|
53
|
+
roleKey: role.key,
|
|
54
|
+
displayName: role.displayName,
|
|
55
|
+
});
|
|
56
|
+
return { created: true };
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
// Only AlreadyExists (gRPC code 6, or its name in the message) means the
|
|
60
|
+
// role is there; any other 409 is a real failure.
|
|
61
|
+
if (e instanceof ZitadelHttpError &&
|
|
62
|
+
e.status === 409 &&
|
|
63
|
+
(e.code === 6 || /already.?exists/i.test(e.message))) {
|
|
64
|
+
return { created: false };
|
|
65
|
+
}
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
async grantProjectRoles(orgId, userId, projectId, roleKeys) {
|
|
70
|
+
// This project's own grant for this user: not one inherited through a
|
|
71
|
+
// project grant from another org. Paged, so a user with many grants
|
|
72
|
+
// cannot hide it.
|
|
73
|
+
const pageSize = 100;
|
|
74
|
+
let existing;
|
|
75
|
+
for (let offset = 0; !existing; offset += pageSize) {
|
|
76
|
+
const found = await call('POST', '/management/v1/users/grants/_search', orgId, {
|
|
77
|
+
query: { offset: String(offset), limit: pageSize },
|
|
78
|
+
queries: [{ userIdQuery: { userId } }, { projectIdQuery: { projectId } }],
|
|
79
|
+
});
|
|
80
|
+
const page = found.result ?? [];
|
|
81
|
+
existing = page.find((g) => (g.userId === undefined || g.userId === userId) &&
|
|
82
|
+
(g.projectId === undefined || g.projectId === projectId) &&
|
|
83
|
+
!g.projectGrantId);
|
|
84
|
+
if (page.length < pageSize)
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
if (!existing) {
|
|
88
|
+
await call('POST', `/management/v1/users/${userId}/grants`, orgId, {
|
|
89
|
+
projectId,
|
|
90
|
+
roleKeys,
|
|
91
|
+
});
|
|
92
|
+
return { changed: true };
|
|
93
|
+
}
|
|
94
|
+
const held = existing.roleKeys ?? [];
|
|
95
|
+
const missing = roleKeys.filter((k) => !held.includes(k));
|
|
96
|
+
if (missing.length === 0)
|
|
97
|
+
return { changed: false };
|
|
98
|
+
await call('PUT', `/management/v1/users/${userId}/grants/${existing.id}`, orgId, {
|
|
99
|
+
roleKeys: [...held, ...missing],
|
|
100
|
+
});
|
|
101
|
+
return { changed: true };
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|