@wtfalch/foundry 0.31.0 → 0.33.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/apply.d.ts +6 -1
- package/dist/bootstrap/bootstrap.d.ts +18 -1
- package/dist/bootstrap/bootstrap.js +167 -28
- package/dist/bootstrap/declare.d.ts +5 -1
- package/dist/bootstrap/declare.js +3 -1
- package/dist/bootstrap/index.d.ts +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +2 -1
- package/dist/org.d.ts +12 -3
- package/dist/org.js +19 -3
- package/dist/plan/boule.d.ts +24 -1
- package/dist/plan/boule.js +29 -1
- package/dist/plan/confirm-upgrade.d.ts +17 -0
- package/dist/plan/confirm-upgrade.js +47 -0
- package/dist/plan/diff.d.ts +3 -1
- package/dist/plan/diff.js +174 -12
- package/dist/plan/executor.d.ts +65 -7
- package/dist/plan/executor.js +164 -45
- package/dist/plan/health-targets.js +1 -1
- package/dist/plan/index.d.ts +5 -2
- package/dist/plan/index.js +2 -1
- package/dist/plan/plan-apply.d.ts +40 -2
- package/dist/plan/plan-apply.js +43 -2
- package/dist/plan/platform-diff.d.ts +39 -10
- package/dist/plan/platform-diff.js +135 -33
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-org.js +2 -0
- package/dist/plan/platform-providers.d.ts +53 -7
- package/dist/plan/platform-providers.js +351 -99
- package/dist/plan/sign-in-org.d.ts +99 -0
- package/dist/plan/sign-in-org.js +133 -0
- package/dist/plan/version-status.d.ts +1 -2
- package/dist/plan.d.ts +30 -0
- package/package.json +1 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { ZitadelPort } from './executor.js';
|
|
2
|
+
/** Scope id: the domain registered as primary on the company's sign-in org. */
|
|
3
|
+
export declare const ZITADEL_ORG_DOMAIN_SCOPE_KEY = "zitadel_org_domain";
|
|
4
|
+
/**
|
|
5
|
+
* Scope id: the id of the sign-in org whose self-registration foundry closed,
|
|
6
|
+
* recorded only after `closeSelfRegistration` returned. While it is missing or
|
|
7
|
+
* names another org, the company plan holds the close step and no owner invite.
|
|
8
|
+
*/
|
|
9
|
+
export declare const ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY = "zitadel_org_registration_closed";
|
|
10
|
+
/** Closes self-registration on the org (idempotent) and records it under `scopeKey`, in that order. */
|
|
11
|
+
export declare function closeSignInOrgRegistration(zitadel: Pick<ZitadelPort, 'closeSelfRegistration'>, orgId: string, recordScopeId: (key: string, id: string) => void, scopeKey?: string): Promise<void>;
|
|
12
|
+
/** Scope id: the domain the sign-in server refused to make primary (its domain policy wants proof). Set instead of failing; the planner reports it as blocked. */
|
|
13
|
+
export declare const ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY = "zitadel_org_domain_blocked";
|
|
14
|
+
/** The fixed text of the blocked item for a refused sign-in domain. No cause, no value but names. */
|
|
15
|
+
export declare function signInDomainBlockedText(orgName: string, domain: string): string;
|
|
16
|
+
/** The domain an org's owners sign in on: its own, or `<slug>.oikos.host` when it has none. */
|
|
17
|
+
export declare function signInOrgDomain(org: {
|
|
18
|
+
slug: string;
|
|
19
|
+
domain: string | null;
|
|
20
|
+
}): string;
|
|
21
|
+
/**
|
|
22
|
+
* Adds `domain` to the ZITADEL org and makes it primary. Both calls are
|
|
23
|
+
* idempotent. A failure after the add leaves the domain on the org but not
|
|
24
|
+
* primary, and nothing is recorded, so the next run repeats both.
|
|
25
|
+
*/
|
|
26
|
+
export declare function registerSignInDomain(zitadel: Pick<ZitadelPort, 'addOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* {@link registerSignInDomain}, except that a refusal to make the domain
|
|
29
|
+
* primary (the sign-in server's domain policy wants proof) is recorded under
|
|
30
|
+
* `blockedScopeKey` and returns `false` instead of throwing on every apply.
|
|
31
|
+
* Any other failure throws. Returns `true` when the domain is primary.
|
|
32
|
+
*/
|
|
33
|
+
export declare function registerSignInDomainOrBlock(zitadel: Pick<ZitadelPort, 'addOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string, recordScopeId: (key: string, id: string) => void, blockedScopeKey?: string): Promise<boolean>;
|
|
34
|
+
export interface EnsureSignInOrgOptions {
|
|
35
|
+
zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'addOrgDomain' | 'setPrimaryOrgDomain' | 'closeSelfRegistration'>;
|
|
36
|
+
/** Where the successful close is recorded (the org id). Absent: not recorded; the caller closes on every run. */
|
|
37
|
+
registrationClosedScopeKey?: string;
|
|
38
|
+
/** Defaults to {@link ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY}. */
|
|
39
|
+
domainBlockedScopeKey?: string;
|
|
40
|
+
/** The ZITADEL org's name. A company uses its slug, so every caller finds the same org. */
|
|
41
|
+
name: string;
|
|
42
|
+
/** The domain to make primary, from {@link signInOrgDomain}. */
|
|
43
|
+
domain: string;
|
|
44
|
+
/** Where the ids are recorded. */
|
|
45
|
+
recordScopeId(key: string, id: string): void;
|
|
46
|
+
/** Called once the org id is recorded and before the domain is registered, so a crash between them does not lose the org. */
|
|
47
|
+
persistScopeIds?(): Promise<void>;
|
|
48
|
+
/** Defaults to `zitadel_org`. */
|
|
49
|
+
orgScopeKey?: string;
|
|
50
|
+
/** Defaults to {@link ZITADEL_ORG_DOMAIN_SCOPE_KEY}. */
|
|
51
|
+
domainScopeKey?: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Finds or creates the company's sign-in organisation by name, registers
|
|
55
|
+
* `domain` on it and makes it primary, then records the org id and the domain.
|
|
56
|
+
* The domain is recorded last, only after both calls succeeded.
|
|
57
|
+
*/
|
|
58
|
+
export declare function ensureSignInOrg(options: EnsureSignInOrgOptions): Promise<{
|
|
59
|
+
orgId: string;
|
|
60
|
+
created: boolean;
|
|
61
|
+
domainBlocked?: true;
|
|
62
|
+
}>;
|
|
63
|
+
/** The user metadata key that holds an owner's personal address. */
|
|
64
|
+
export declare const PERSONAL_EMAIL_METADATA_KEY = "personal_email";
|
|
65
|
+
export interface InviteOwnerOptions {
|
|
66
|
+
zitadel: Pick<ZitadelPort, 'inviteOrgOwner' | 'setUserMetadata' | 'getUserMetadata'>;
|
|
67
|
+
mail: {
|
|
68
|
+
send(to: string, link: string): Promise<void>;
|
|
69
|
+
};
|
|
70
|
+
/** The sign-in org the account is made in. */
|
|
71
|
+
orgId: string;
|
|
72
|
+
/** The platform's login app, where the link lands (it serves `/verify`). */
|
|
73
|
+
authIssuer: string;
|
|
74
|
+
owner: {
|
|
75
|
+
givenName: string;
|
|
76
|
+
familyName: string;
|
|
77
|
+
/** The owner's work address: the account signs in as its local part. */
|
|
78
|
+
mailboxAddress: string;
|
|
79
|
+
/** Where the first-sign-in link is mailed and what the `personal_email` metadata holds. */
|
|
80
|
+
personalEmail: string;
|
|
81
|
+
};
|
|
82
|
+
/** The user id foundry recorded when it invited this owner before. An existing account with another id, and no personal_email metadata of the owner's, is refused. */
|
|
83
|
+
invitedUserId?: string;
|
|
84
|
+
/** Called with the invite code and the whole link before they are mailed, so the caller can mask them. */
|
|
85
|
+
onSecret?: (value: string) => void;
|
|
86
|
+
/** Names the step in errors, for example `zitadel:owner for acme`. */
|
|
87
|
+
label: string;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Makes the owner's account (username = local part of the work address, email =
|
|
91
|
+
* the work address), keeps the personal address as metadata, and mails the
|
|
92
|
+
* first-sign-in link to it. The invite code ZITADEL returns goes only into that
|
|
93
|
+
* mail (30 second timeout); no error holds it. A failure after the account
|
|
94
|
+
* exists leaves the account, and the next run finds it by username and mints a
|
|
95
|
+
* new code.
|
|
96
|
+
*/
|
|
97
|
+
export declare function inviteOwnerWithLink(options: InviteOwnerOptions): Promise<{
|
|
98
|
+
userId: string;
|
|
99
|
+
}>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/** Scope id: the domain registered as primary on the company's sign-in org. */
|
|
2
|
+
export const ZITADEL_ORG_DOMAIN_SCOPE_KEY = 'zitadel_org_domain';
|
|
3
|
+
/**
|
|
4
|
+
* Scope id: the id of the sign-in org whose self-registration foundry closed,
|
|
5
|
+
* recorded only after `closeSelfRegistration` returned. While it is missing or
|
|
6
|
+
* names another org, the company plan holds the close step and no owner invite.
|
|
7
|
+
*/
|
|
8
|
+
export const ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY = 'zitadel_org_registration_closed';
|
|
9
|
+
/** Closes self-registration on the org (idempotent) and records it under `scopeKey`, in that order. */
|
|
10
|
+
export async function closeSignInOrgRegistration(zitadel, orgId, recordScopeId, scopeKey = ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY) {
|
|
11
|
+
await zitadel.closeSelfRegistration(orgId);
|
|
12
|
+
recordScopeId(scopeKey, orgId);
|
|
13
|
+
}
|
|
14
|
+
/** Scope id: the domain the sign-in server refused to make primary (its domain policy wants proof). Set instead of failing; the planner reports it as blocked. */
|
|
15
|
+
export const ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY = 'zitadel_org_domain_blocked';
|
|
16
|
+
/** The fixed text of the blocked item for a refused sign-in domain. No cause, no value but names. */
|
|
17
|
+
export function signInDomainBlockedText(orgName, domain) {
|
|
18
|
+
return `${orgName}'s sign-in domain ${domain} is blocked, not planned: the sign-in server refused to make it primary because its domain policy wants proof of ownership. Set the policy ValidateOrgDomains to false (and UserLoginMustBeDomain to true) on the sign-in server, then remove the recorded key ${ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY}`;
|
|
19
|
+
}
|
|
20
|
+
/** True for the providers' "domain is not verified" refusal of `setPrimaryOrgDomain`. */
|
|
21
|
+
function isPrimaryDomainRefused(error) {
|
|
22
|
+
return error instanceof Error && /is not verified/.test(error.message);
|
|
23
|
+
}
|
|
24
|
+
/** The domain an org's owners sign in on: its own, or `<slug>.oikos.host` when it has none. */
|
|
25
|
+
export function signInOrgDomain(org) {
|
|
26
|
+
return (org.domain ?? `${org.slug}.oikos.host`).toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Adds `domain` to the ZITADEL org and makes it primary. Both calls are
|
|
30
|
+
* idempotent. A failure after the add leaves the domain on the org but not
|
|
31
|
+
* primary, and nothing is recorded, so the next run repeats both.
|
|
32
|
+
*/
|
|
33
|
+
export async function registerSignInDomain(zitadel, orgId, domain) {
|
|
34
|
+
await zitadel.addOrgDomain(orgId, domain);
|
|
35
|
+
await zitadel.setPrimaryOrgDomain(orgId, domain);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* {@link registerSignInDomain}, except that a refusal to make the domain
|
|
39
|
+
* primary (the sign-in server's domain policy wants proof) is recorded under
|
|
40
|
+
* `blockedScopeKey` and returns `false` instead of throwing on every apply.
|
|
41
|
+
* Any other failure throws. Returns `true` when the domain is primary.
|
|
42
|
+
*/
|
|
43
|
+
export async function registerSignInDomainOrBlock(zitadel, orgId, domain, recordScopeId, blockedScopeKey = ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY) {
|
|
44
|
+
try {
|
|
45
|
+
await registerSignInDomain(zitadel, orgId, domain);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (!isPrimaryDomainRefused(error))
|
|
50
|
+
throw error;
|
|
51
|
+
recordScopeId(blockedScopeKey, domain);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Finds or creates the company's sign-in organisation by name, registers
|
|
57
|
+
* `domain` on it and makes it primary, then records the org id and the domain.
|
|
58
|
+
* The domain is recorded last, only after both calls succeeded.
|
|
59
|
+
*/
|
|
60
|
+
export async function ensureSignInOrg(options) {
|
|
61
|
+
const { zitadel, name, domain, recordScopeId } = options;
|
|
62
|
+
const { org, created } = await zitadel.findOrCreateOrg(name);
|
|
63
|
+
recordScopeId(options.orgScopeKey ?? 'zitadel_org', org.id);
|
|
64
|
+
await options.persistScopeIds?.();
|
|
65
|
+
// Before any owner invite can run: a stranger must not register the owner's
|
|
66
|
+
// username on an org that still inherits the instance's open registration.
|
|
67
|
+
// A failure here stops the step with the org recorded and no close recorded:
|
|
68
|
+
// the company plan then holds the close step and no invite until it succeeds.
|
|
69
|
+
await zitadel.closeSelfRegistration(org.id);
|
|
70
|
+
if (options.registrationClosedScopeKey) {
|
|
71
|
+
recordScopeId(options.registrationClosedScopeKey, org.id);
|
|
72
|
+
}
|
|
73
|
+
const registered = await registerSignInDomainOrBlock(zitadel, org.id, domain, recordScopeId, options.domainBlockedScopeKey);
|
|
74
|
+
if (!registered)
|
|
75
|
+
return { orgId: org.id, created, domainBlocked: true };
|
|
76
|
+
recordScopeId(options.domainScopeKey ?? ZITADEL_ORG_DOMAIN_SCOPE_KEY, domain);
|
|
77
|
+
return { orgId: org.id, created };
|
|
78
|
+
}
|
|
79
|
+
/** The user metadata key that holds an owner's personal address. */
|
|
80
|
+
export const PERSONAL_EMAIL_METADATA_KEY = 'personal_email';
|
|
81
|
+
/** How long the owner invite mail may take before the step gives up on it. */
|
|
82
|
+
const OWNER_INVITE_MAIL_TIMEOUT_MS = 30_000;
|
|
83
|
+
async function withTimeout(work, ms) {
|
|
84
|
+
let timer;
|
|
85
|
+
const timeout = new Promise((_, reject) => {
|
|
86
|
+
timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
return await Promise.race([work, timeout]);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Makes the owner's account (username = local part of the work address, email =
|
|
97
|
+
* the work address), keeps the personal address as metadata, and mails the
|
|
98
|
+
* first-sign-in link to it. The invite code ZITADEL returns goes only into that
|
|
99
|
+
* mail (30 second timeout); no error holds it. A failure after the account
|
|
100
|
+
* exists leaves the account, and the next run finds it by username and mints a
|
|
101
|
+
* new code.
|
|
102
|
+
*/
|
|
103
|
+
export async function inviteOwnerWithLink(options) {
|
|
104
|
+
const { zitadel, mail, orgId, authIssuer, owner, label } = options;
|
|
105
|
+
const { userId, created, inviteCode } = await zitadel.inviteOrgOwner(orgId, {
|
|
106
|
+
givenName: owner.givenName,
|
|
107
|
+
familyName: owner.familyName,
|
|
108
|
+
email: owner.mailboxAddress,
|
|
109
|
+
username: owner.mailboxAddress.slice(0, owner.mailboxAddress.indexOf('@')),
|
|
110
|
+
}, undefined, true);
|
|
111
|
+
if (!created && !inviteCode && userId !== options.invitedUserId) {
|
|
112
|
+
// An account that already has a sign-in method and no record of ours: not adopted.
|
|
113
|
+
// `personal_email` is written only by this function, through the admin API.
|
|
114
|
+
const metadata = await zitadel.getUserMetadata(orgId, userId, PERSONAL_EMAIL_METADATA_KEY);
|
|
115
|
+
if (metadata?.toLowerCase() !== owner.personalEmail.toLowerCase()) {
|
|
116
|
+
throw new Error(`${label}: in ZITADEL org ${orgId} an account with this username already exists and was not invited by foundry`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
await zitadel.setUserMetadata(orgId, userId, PERSONAL_EMAIL_METADATA_KEY, owner.personalEmail);
|
|
120
|
+
if (inviteCode) {
|
|
121
|
+
const link = `${authIssuer}/verify?userId=${encodeURIComponent(userId)}&code=${encodeURIComponent(inviteCode)}&organization=${encodeURIComponent(orgId)}&invite=true`;
|
|
122
|
+
options.onSecret?.(inviteCode);
|
|
123
|
+
options.onSecret?.(link);
|
|
124
|
+
try {
|
|
125
|
+
await withTimeout(mail.send(owner.personalEmail, link), OWNER_INVITE_MAIL_TIMEOUT_MS);
|
|
126
|
+
}
|
|
127
|
+
catch (_cause) {
|
|
128
|
+
// The mail port's own error may carry the link, and the link carries the code.
|
|
129
|
+
throw new Error(`${label}: the invite mail could not be sent; the account ${userId} exists and the next apply sends a new link`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { userId };
|
|
133
|
+
}
|
|
@@ -21,8 +21,7 @@ import type { VersionChangeItem } from '../plan.js';
|
|
|
21
21
|
* `coolifyApplicationUuid` is the id `plan/diff.ts`'s `appStepKey` keys an
|
|
22
22
|
* org's hosting entry under in `AppliedState.serviceIds`
|
|
23
23
|
* (`` `coolify:app:${appStepKey(org, entry.key)}` ``, `org.ts`); `repo` is
|
|
24
|
-
* `HostingEntry.repo
|
|
25
|
-
* declared resolves to (`plan/executor.ts`'s own `DEFAULT_APP_REPO`).
|
|
24
|
+
* `HostingEntry.repo`.
|
|
26
25
|
*/
|
|
27
26
|
export interface OrgAppVersionRef {
|
|
28
27
|
orgId: OrgId;
|
package/dist/plan.d.ts
CHANGED
|
@@ -62,6 +62,24 @@ export interface Plan {
|
|
|
62
62
|
* this type with none.
|
|
63
63
|
*/
|
|
64
64
|
versionChanges?: VersionChangeItem[];
|
|
65
|
+
/**
|
|
66
|
+
* Things a person should see that block no step: an optional credential not
|
|
67
|
+
* held yet. Never in `unmanaged`, so a plan with no steps and no blocked
|
|
68
|
+
* item still reads as settled. Optional: a consumer that does not know it
|
|
69
|
+
* loses only the notes.
|
|
70
|
+
*/
|
|
71
|
+
notes?: PlanNote[];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* One advisory line of a {@link Plan}. It never stops a step and is never a
|
|
75
|
+
* {@link PlanStep}. It names a credential, never a value.
|
|
76
|
+
*/
|
|
77
|
+
export interface PlanNote {
|
|
78
|
+
/** Stable, e.g. `uptime:CHAT_ACCESS_TOKEN`. */
|
|
79
|
+
key: string;
|
|
80
|
+
/** The service or step this is about, e.g. `uptime`. */
|
|
81
|
+
service: string;
|
|
82
|
+
description: string;
|
|
65
83
|
}
|
|
66
84
|
/**
|
|
67
85
|
* The action kind a {@link VersionChangeItem} carries -- a distinct, typed
|
|
@@ -99,4 +117,16 @@ export interface VersionChangeItem {
|
|
|
99
117
|
/** The service this line is about, e.g. 'mail', 'archon', 'zitadel'. */
|
|
100
118
|
service: string;
|
|
101
119
|
description: string;
|
|
120
|
+
/** The version running or last applied; absent when that is unknown. */
|
|
121
|
+
from?: string;
|
|
122
|
+
/** The version the declaration or the foundry pin now asks for. */
|
|
123
|
+
to?: string;
|
|
124
|
+
/**
|
|
125
|
+
* True when `apply` and `applyPlatform` refuse the plan until
|
|
126
|
+
* `confirmUpgrade` names {@link service}: any change on a stateful service
|
|
127
|
+
* (`plan/confirm-upgrade.ts`), or a major or minor jump on any service. An
|
|
128
|
+
* unknown running version is never a change, so it is false. So is a line
|
|
129
|
+
* `apply` does not act on ("not applied by foundry").
|
|
130
|
+
*/
|
|
131
|
+
needsConfirmation: boolean;
|
|
102
132
|
}
|