@wtfalch/foundry 0.71.0 → 0.72.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.
Files changed (38) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/modules/sign-in.js +81 -9
  3. package/dist/modules/types.d.ts +2 -0
  4. package/dist/native-build.d.ts +4 -1
  5. package/dist/native-build.js +8 -2
  6. package/dist/org.d.ts +18 -1
  7. package/dist/org.js +40 -0
  8. package/dist/plan/boule.d.ts +15 -0
  9. package/dist/plan/boule.js +13 -0
  10. package/dist/plan/broker-registrar-token.d.ts +69 -0
  11. package/dist/plan/broker-registrar-token.js +215 -0
  12. package/dist/plan/broker-registrar.d.ts +2 -0
  13. package/dist/plan/broker-registrar.js +5 -1
  14. package/dist/plan/diff.d.ts +13 -0
  15. package/dist/plan/diff.js +26 -5
  16. package/dist/plan/executor.d.ts +66 -0
  17. package/dist/plan/executor.js +136 -8
  18. package/dist/plan/health-check-settings.d.ts +13 -0
  19. package/dist/plan/health-check-settings.js +17 -0
  20. package/dist/plan/own-diff.d.ts +32 -0
  21. package/dist/plan/own-diff.js +67 -10
  22. package/dist/plan/own-hostnames.d.ts +8 -0
  23. package/dist/plan/own-hostnames.js +14 -0
  24. package/dist/plan/own-services.d.ts +7 -0
  25. package/dist/plan/own-services.js +2 -0
  26. package/dist/plan/plan-apply.d.ts +15 -0
  27. package/dist/plan/plan-apply.js +21 -0
  28. package/dist/plan/platform-diff.d.ts +9 -5
  29. package/dist/plan/platform-diff.js +16 -10
  30. package/dist/plan/platform-providers.d.ts +24 -2
  31. package/dist/plan/platform-providers.js +155 -12
  32. package/dist/plan/signin-mail.d.ts +18 -5
  33. package/dist/plan/signin-mail.js +14 -6
  34. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  35. package/dist/plan/zitadel-login-policy.js +72 -46
  36. package/dist/plan/zitadel-machine-user.d.ts +14 -1
  37. package/dist/plan/zitadel-machine-user.js +96 -13
  38. package/package.json +3 -3
@@ -2,7 +2,7 @@
2
2
  * The sign-in code mail of a company that runs its own sign-in: the company's name in the sender and
3
3
  * the text, the platform's design tokens for the look. ZITADEL's email-code message is built from the
4
4
  * instance's custom text and label policy; both are written through `PlatformZitadelMailBranding`
5
- * (`@wtfalch/providers` 0.14.0), each only when it differs. The code itself (six digits, five minutes)
5
+ * (`@wtfalch/providers`), each only when it differs. The code itself (six digits, five minutes)
6
6
  * is the instance's email-OTP generator.
7
7
  *
8
8
  * The look: no company declares a design foundry can read (an `OrgTheme` lives in cms and reaches
@@ -10,7 +10,7 @@
10
10
  * (`SIGNIN_MAIL_LABEL_POLICY`). No logo: ZITADEL takes it as a multipart upload the client does not make.
11
11
  */
12
12
  /** Raise it when the text or the look changes, so an applied company plans its sign-in step again. */
13
- export const AUTH_SIGNIN_MAIL_VERSION = '2';
13
+ export const AUTH_SIGNIN_MAIL_VERSION = '3';
14
14
  /** `signinMailMarker` of the company name and version last written, recorded after both writes. */
15
15
  export const PLATFORM_AUTH_SIGNIN_MAIL_SCOPE_KEY = 'auth_signin_mail_applied';
16
16
  /** The code the mail carries: six digits, valid five minutes (`123-456` in the mail, see `signinMailText`). */
@@ -37,6 +37,11 @@ export const SIGNIN_MAIL_LANGUAGE = 'en';
37
37
  * holds the code, split in the middle with Go's `slice` (`123-456`), because ZITADEL renders the
38
38
  * greeting as the large heading, so the code is the first thing the reader sees. No greeting by
39
39
  * name: the login fills names from the email address, so it would read "Wtfalch Wtfalch".
40
+ *
41
+ * Paragraphs: ZITADEL v4.17.3 puts `{{.Text}}` in one `<div>` of its built-in mail, where a line
42
+ * break collapses to a space. It escapes the field (`html/template`), then un-escapes the whole mail
43
+ * before sending it over SMTP (`internal/notification/types/user_email.go:43`,
44
+ * `html.UnescapeString`), so `<p>` tags in the text arrive as tags. There is no mail template API.
40
45
  */
41
46
  export function signinMailText(companyName) {
42
47
  const name = signinMailName(companyName);
@@ -45,7 +50,7 @@ export function signinMailText(companyName) {
45
50
  preHeader: `Your ${name} sign-in code`,
46
51
  subject: `Your ${name} sign-in code`,
47
52
  greeting: '{{slice .OTP 0 3}}-{{slice .OTP 3}}',
48
- text: 'Enter this code to sign in. It works for five minutes. Or use the button to sign in on this device.',
53
+ text: `<p style="margin:0 0 12px 0">Enter this code to sign in. It works for five minutes.</p><p style="margin:0">Or use the button to sign in on this device.</p>`,
49
54
  buttonText: 'Sign in',
50
55
  footerText: name,
51
56
  };
@@ -66,10 +71,13 @@ export const SIGNIN_MAIL_LABEL_POLICY = {
66
71
  warnColorDark: '#FF9C8C',
67
72
  disableWatermark: true,
68
73
  };
69
- /** Writes the text, the look and the code, each only when it differs. Throws the client's fixed-text error. */
70
- export async function applySigninMail(zitadel, companyName) {
74
+ /** Names the default organisation after the company, then writes the text, the look and the code, each only when it differs. Throws the client's fixed-text error. */
75
+ export async function applySigninMail(zitadel, companyName, defaultOrgId) {
76
+ const name = await zitadel.ensureOrgName(defaultOrgId, signinMailName(companyName));
71
77
  const text = await zitadel.ensureEmailOtpMessageText(SIGNIN_MAIL_LANGUAGE, signinMailText(companyName));
72
78
  const look = await zitadel.ensureLabelPolicy({ ...SIGNIN_MAIL_LABEL_POLICY });
73
79
  const code = await zitadel.ensureEmailOtpGenerator({ ...SIGNIN_MAIL_CODE });
74
- return { changed: text.changed || look.changed || code.changed };
80
+ return {
81
+ changed: name.changed || text.changed || look.changed || code.changed,
82
+ };
75
83
  }
@@ -20,4 +20,4 @@ export interface ZitadelLoginPolicyOptions {
20
20
  * policy (POST), so a later change to the default cannot turn registration
21
21
  * back on. A custom policy keeps its own factors and IdPs (PUT).
22
22
  */
23
- export declare function createZitadelLoginPolicy(options: ZitadelLoginPolicyOptions): Pick<ZitadelPort, 'closeSelfRegistration' | 'enforceSecondFactor'>;
23
+ export declare function createZitadelLoginPolicy(options: ZitadelLoginPolicyOptions): Required<Pick<ZitadelPort, 'closeSelfRegistration' | 'enforceSecondFactor' | 'setCustomersLoginPolicy'>>;
@@ -29,7 +29,22 @@ const SECOND_FACTOR_SETTINGS = {
29
29
  passwordlessType: 'PASSWORDLESS_TYPE_ALLOWED',
30
30
  mfaInitSkipLifetime: '0s',
31
31
  };
32
+ /**
33
+ * What {@link ZitadelPort.setCustomersLoginPolicy} sets: a superset of what any customer app needs, so one
34
+ * policy serves every app of the customers org. `allowRegister` is not here: `customers.signUp` owns it.
35
+ */
36
+ const CUSTOMERS_POLICY_SETTINGS = {
37
+ forceMfa: false,
38
+ allowExternalIdp: false,
39
+ allowUsernamePassword: true,
40
+ passwordlessType: 'PASSWORDLESS_TYPE_ALLOWED',
41
+ };
32
42
  const SECOND_FACTORS = ['SECOND_FACTOR_TYPE_OTP', 'SECOND_FACTOR_TYPE_U2F'];
43
+ /**
44
+ * The customers policy also keeps the emailed code as a second factor, and never removes it: the email-code
45
+ * session check may need it. The login app narrows the factors it offers an app.
46
+ */
47
+ const CUSTOMERS_SECOND_FACTORS = [...SECOND_FACTORS, 'SECOND_FACTOR_TYPE_OTP_EMAIL'];
33
48
  const MULTI_FACTORS = ['MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION'];
34
49
  /** ZITADEL leaves false booleans and zero durations out of a read. */
35
50
  const isZeroDuration = (value) => value === undefined || (typeof value === 'string' && /^0+(\.0+)?s$/.test(value));
@@ -90,56 +105,67 @@ export function createZitadelLoginPolicy(options) {
90
105
  }
91
106
  return settings;
92
107
  };
108
+ /**
109
+ * Makes the org's login policy hold `want` and exactly these factor lists, writing nothing when it already
110
+ * does. Any setting not in `want` is copied back as it is. An org on the instance default gets its own
111
+ * policy (POST); a custom one is changed by PUT, then by adding the missing factors before removing the
112
+ * extra ones, so the org is never left with no second factor it needs.
113
+ */
114
+ async function conform(orgId, want, wantSecond, wantMulti) {
115
+ const data = await call('GET', orgId, undefined);
116
+ const policy = data.policy ?? {};
117
+ const isDefault = data.isDefault === true || policy.isDefault === true;
118
+ const secondFactors = stringList(policy.secondFactors);
119
+ const multiFactors = stringList(policy.multiFactors);
120
+ const settingsDiffer = Object.entries(want).some(([key, value]) => typeof value === 'boolean'
121
+ ? (policy[key] === true) !== value
122
+ : value === '0s'
123
+ ? !isZeroDuration(policy[key])
124
+ : policy[key] !== value);
125
+ const factorsDiffer = !sameSet(secondFactors, wantSecond) || !sameSet(multiFactors, wantMulti);
126
+ if (!isDefault && !settingsDiffer && !factorsDiffer)
127
+ return { changed: false };
128
+ const settings = { ...settingsOf(policy), ...want };
129
+ if (isDefault) {
130
+ // The org's own policy, written whole: the factor lists go in the same body.
131
+ await call('POST', orgId, {
132
+ ...settings,
133
+ secondFactors: wantSecond,
134
+ multiFactors: wantMulti,
135
+ idps: (policy.idps ?? []).map((idp) => ({
136
+ idpId: idp.idpId,
137
+ ownerType: 'IDP_OWNER_TYPE_SYSTEM',
138
+ })),
139
+ });
140
+ return { changed: true };
141
+ }
142
+ if (settingsDiffer)
143
+ await call('PUT', orgId, settings);
144
+ const factorPath = '/management/v1/policies/login';
145
+ for (const type of wantSecond.filter((t) => !secondFactors.includes(t))) {
146
+ await call('POST', orgId, { type }, `${factorPath}/second_factors`);
147
+ }
148
+ for (const type of wantMulti.filter((t) => !multiFactors.includes(t))) {
149
+ await call('POST', orgId, { type }, `${factorPath}/multi_factors`);
150
+ }
151
+ for (const type of secondFactors.filter((t) => !wantSecond.includes(t))) {
152
+ await call('DELETE', orgId, undefined, `${factorPath}/second_factors/${encodeURIComponent(type)}`);
153
+ }
154
+ for (const type of multiFactors.filter((t) => !wantMulti.includes(t))) {
155
+ await call('DELETE', orgId, undefined, `${factorPath}/multi_factors/${encodeURIComponent(type)}`);
156
+ }
157
+ return { changed: true };
158
+ }
93
159
  return {
94
160
  async enforceSecondFactor(orgId) {
95
161
  if (!orgId)
96
162
  throw new Error('enforceSecondFactor: orgId is required');
97
- const data = await call('GET', orgId, undefined);
98
- const policy = data.policy ?? {};
99
- const isDefault = data.isDefault === true || policy.isDefault === true;
100
- const secondFactors = stringList(policy.secondFactors);
101
- const multiFactors = stringList(policy.multiFactors);
102
- const want = SECOND_FACTOR_SETTINGS;
103
- const settingsDiffer = (policy.forceMfa === true) !== want.forceMfa ||
104
- (policy.allowRegister === true) !== want.allowRegister ||
105
- (policy.allowExternalIdp === true) !== want.allowExternalIdp ||
106
- (policy.allowUsernamePassword === true) !== want.allowUsernamePassword ||
107
- policy.passwordlessType !== want.passwordlessType ||
108
- !isZeroDuration(policy.mfaInitSkipLifetime);
109
- const factorsDiffer = !sameSet(secondFactors, SECOND_FACTORS) || !sameSet(multiFactors, MULTI_FACTORS);
110
- if (!isDefault && !settingsDiffer && !factorsDiffer)
111
- return { changed: false };
112
- const settings = { ...settingsOf(policy), ...want };
113
- if (isDefault) {
114
- // The org's own policy, written whole: the factor lists go in the same body.
115
- await call('POST', orgId, {
116
- ...settings,
117
- secondFactors: SECOND_FACTORS,
118
- multiFactors: MULTI_FACTORS,
119
- idps: (policy.idps ?? []).map((idp) => ({
120
- idpId: idp.idpId,
121
- ownerType: 'IDP_OWNER_TYPE_SYSTEM',
122
- })),
123
- });
124
- return { changed: true };
125
- }
126
- if (settingsDiffer)
127
- await call('PUT', orgId, settings);
128
- // Add before remove, so the org is never left with no second factor.
129
- const factorPath = '/management/v1/policies/login';
130
- for (const type of SECOND_FACTORS.filter((t) => !secondFactors.includes(t))) {
131
- await call('POST', orgId, { type }, `${factorPath}/second_factors`);
132
- }
133
- for (const type of MULTI_FACTORS.filter((t) => !multiFactors.includes(t))) {
134
- await call('POST', orgId, { type }, `${factorPath}/multi_factors`);
135
- }
136
- for (const type of secondFactors.filter((t) => !SECOND_FACTORS.includes(t))) {
137
- await call('DELETE', orgId, undefined, `${factorPath}/second_factors/${encodeURIComponent(type)}`);
138
- }
139
- for (const type of multiFactors.filter((t) => !MULTI_FACTORS.includes(t))) {
140
- await call('DELETE', orgId, undefined, `${factorPath}/multi_factors/${encodeURIComponent(type)}`);
141
- }
142
- return { changed: true };
163
+ return conform(orgId, SECOND_FACTOR_SETTINGS, SECOND_FACTORS, MULTI_FACTORS);
164
+ },
165
+ async setCustomersLoginPolicy(orgId) {
166
+ if (!orgId)
167
+ throw new Error('setCustomersLoginPolicy: orgId is required');
168
+ return conform(orgId, CUSTOMERS_POLICY_SETTINGS, CUSTOMERS_SECOND_FACTORS, MULTI_FACTORS);
143
169
  },
144
170
  async closeSelfRegistration(orgId) {
145
171
  if (!orgId)
@@ -78,6 +78,13 @@ export declare class ZitadelLoginClientRoleConflictError extends Error {
78
78
  }
79
79
  /** The read-only organisation role a directory reader holds. It lists the organisation's users and changes nothing. */
80
80
  export declare const ORG_DIRECTORY_READER_ROLE = "ORG_OWNER_VIEWER";
81
+ /**
82
+ * Boule removes a customer with this credential (boule#495). ZITADEL's narrowest built-in organisation role
83
+ * that holds `user.delete` is `ORG_USER_MANAGER`; it cannot be cut down to delete alone (it also lets the
84
+ * holder change users in that organisation). It is granted in the customers organisation only, never in the
85
+ * company's.
86
+ */
87
+ export declare const ORG_DIRECTORY_USER_MANAGER_ROLE = "ORG_USER_MANAGER";
81
88
  /**
82
89
  * C3: thrown by `mintOrgDirectoryReader` when the user already exists with an organisation role other
83
90
  * than the read-only one. That user was not made by foundry; granting it more or handing out its PAT
@@ -88,6 +95,12 @@ export declare class ZitadelDirectoryRoleConflictError extends Error {
88
95
  readonly roles: readonly string[];
89
96
  constructor(userId: string, roles: readonly string[]);
90
97
  }
98
+ /**
99
+ * The organisation role of the broker's registrar user in each organisation it serves: it may make
100
+ * projects there, and a project it makes it owns. No member, user or login-policy rights, which
101
+ * `ORG_OWNER` would add.
102
+ */
103
+ export declare const REGISTRAR_ROLE = "ORG_PROJECT_CREATOR";
91
104
  export interface ZitadelMinterOptions {
92
105
  /** The new instance's base URL, e.g. `https://auth.companyincluded.com`. */
93
106
  url: string;
@@ -96,4 +109,4 @@ export interface ZitadelMinterOptions {
96
109
  fetchImpl?: typeof fetch;
97
110
  instanceHost?: string;
98
111
  }
99
- export declare function createZitadelAdminMinter(options: ZitadelMinterOptions): Pick<ZitadelPort, 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName' | 'mintOrgDirectoryReader' | 'revokeOrgDirectoryReader'>;
112
+ export declare function createZitadelAdminMinter(options: ZitadelMinterOptions): Pick<ZitadelPort, 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName' | 'mintOrgDirectoryReader' | 'revokeOrgDirectoryReader' | 'ensureOrgDirectoryRoles' | 'ensureRegistrarUser' | 'mintRegistrarToken'>;
@@ -86,6 +86,14 @@ export class ZitadelLoginClientRoleConflictError extends Error {
86
86
  }
87
87
  /** The read-only organisation role a directory reader holds. It lists the organisation's users and changes nothing. */
88
88
  export const ORG_DIRECTORY_READER_ROLE = 'ORG_OWNER_VIEWER';
89
+ /**
90
+ * Boule removes a customer with this credential (boule#495). ZITADEL's narrowest built-in organisation role
91
+ * that holds `user.delete` is `ORG_USER_MANAGER`; it cannot be cut down to delete alone (it also lets the
92
+ * holder change users in that organisation). It is granted in the customers organisation only, never in the
93
+ * company's.
94
+ */
95
+ export const ORG_DIRECTORY_USER_MANAGER_ROLE = 'ORG_USER_MANAGER';
96
+ const ORG_DIRECTORY_ROLES = [ORG_DIRECTORY_READER_ROLE, ORG_DIRECTORY_USER_MANAGER_ROLE];
89
97
  /**
90
98
  * C3: thrown by `mintOrgDirectoryReader` when the user already exists with an organisation role other
91
99
  * than the read-only one. That user was not made by foundry; granting it more or handing out its PAT
@@ -95,12 +103,20 @@ export class ZitadelDirectoryRoleConflictError extends Error {
95
103
  userId;
96
104
  roles;
97
105
  constructor(userId, roles) {
98
- super(`ZITADEL machine user ${userId} already exists with organisation role(s) [${roles.join(', ') || 'none'}], not only [${ORG_DIRECTORY_READER_ROLE}] -- refusing to reuse it as a directory reader`);
106
+ super(`ZITADEL machine user ${userId} already exists with organisation role(s) [${roles.join(', ') || 'none'}], not only [${ORG_DIRECTORY_ROLES.join(', ')}] -- refusing to reuse it as a directory reader`);
99
107
  this.userId = userId;
100
108
  this.roles = roles;
101
109
  this.name = 'ZitadelDirectoryRoleConflictError';
102
110
  }
103
111
  }
112
+ /** Each call to ZITADEL gives up after this long. */
113
+ const CALL_TIMEOUT_MS = 30_000;
114
+ /**
115
+ * The organisation role of the broker's registrar user in each organisation it serves: it may make
116
+ * projects there, and a project it makes it owns. No member, user or login-policy rights, which
117
+ * `ORG_OWNER` would add.
118
+ */
119
+ export const REGISTRAR_ROLE = 'ORG_PROJECT_CREATOR';
104
120
  export function createZitadelAdminMinter(options) {
105
121
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
106
122
  async function call(method, path, body, orgId) {
@@ -119,6 +135,7 @@ export function createZitadelAdminMinter(options) {
119
135
  ...(orgId ? { 'x-zitadel-orgid': orgId } : {}),
120
136
  },
121
137
  body: body === undefined ? undefined : JSON.stringify(body),
138
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
122
139
  });
123
140
  const text = await response.text();
124
141
  let data = {};
@@ -306,21 +323,12 @@ export function createZitadelAdminMinter(options) {
306
323
  if (!userId)
307
324
  throw new Error('ZITADEL did not return the machine user id');
308
325
  if (existing) {
309
- const members = await call('POST', '/management/v1/orgs/me/members/_search', { queries: [{ userIdQuery: { userId } }] }, orgId);
310
- const roles = (members.result ?? [])
311
- .filter((m) => m.userId === userId)
312
- .flatMap((m) => m.roles ?? []);
313
- if (roles.some((role) => role !== ORG_DIRECTORY_READER_ROLE)) {
326
+ const roles = await memberRoles(orgId, userId);
327
+ if (roles.some((role) => !ORG_DIRECTORY_ROLES.includes(role))) {
314
328
  throw new ZitadelDirectoryRoleConflictError(userId, roles);
315
329
  }
316
330
  }
317
- try {
318
- await call('POST', '/management/v1/orgs/me/members', { userId, roles: [ORG_DIRECTORY_READER_ROLE] }, orgId);
319
- }
320
- catch (e) {
321
- if (!(e instanceof ZitadelHttpError && e.status === 409))
322
- throw e;
323
- }
331
+ await ensureOrgDirectoryRoles(orgId, userId);
324
332
  await revokeAllPats(orgId, userId);
325
333
  const pat = await call('POST', `/management/v1/users/${userId}/pats`, {}, orgId);
326
334
  const patId = typeof pat.tokenId === 'string' && pat.tokenId !== '' ? pat.tokenId : undefined;
@@ -329,6 +337,78 @@ export function createZitadelAdminMinter(options) {
329
337
  }
330
338
  return { userId, token: pat.token, patId };
331
339
  }
340
+ /**
341
+ * The broker's registrar user: a machine user of its own in `orgIds[0]` (found by `userName`, else made)
342
+ * holding {@link REGISTRAR_ROLE} in every org of `orgIds`, each grant added only when the org does not
343
+ * have it yet (a 409 is the same). Mints no token, so it is safe to repeat whenever an org joins the
344
+ * list. Calls follow ZITADEL's published v1 management API and have not been run against a live
345
+ * instance.
346
+ */
347
+ async function ensureRegistrarUser(orgIds, input) {
348
+ const home = orgIds[0];
349
+ if (!home)
350
+ throw new Error('the registrar user needs at least one organisation');
351
+ const found = await call('POST', '/management/v1/users/_search', {
352
+ queries: [
353
+ { userNameQuery: { userName: input.userName, method: 'TEXT_QUERY_METHOD_EQUALS' } },
354
+ ],
355
+ }, home);
356
+ let userId = found.result?.[0]?.id;
357
+ if (!userId) {
358
+ const created = await call('POST', '/management/v1/users/machine', {
359
+ userName: input.userName,
360
+ name: input.name,
361
+ accessTokenType: 'ACCESS_TOKEN_TYPE_BEARER',
362
+ }, home);
363
+ userId = typeof created.userId === 'string' ? created.userId : undefined;
364
+ }
365
+ if (!userId)
366
+ throw new Error('ZITADEL did not return the machine user id');
367
+ for (const orgId of new Set(orgIds)) {
368
+ try {
369
+ await call('POST', '/management/v1/orgs/me/members', { userId, roles: [REGISTRAR_ROLE] }, orgId);
370
+ }
371
+ catch (e) {
372
+ if (!(e instanceof ZitadelHttpError && e.status === 409))
373
+ throw e;
374
+ }
375
+ }
376
+ return { userId };
377
+ }
378
+ /** A fresh token for the registrar user; every token it holds is revoked first, so one stays live. */
379
+ async function mintRegistrarToken(homeOrgId, userId) {
380
+ await revokeAllPats(homeOrgId, userId);
381
+ const pat = await call('POST', `/management/v1/users/${userId}/pats`, {}, homeOrgId);
382
+ const patId = typeof pat.tokenId === 'string' && pat.tokenId !== '' ? pat.tokenId : undefined;
383
+ if (typeof pat.token !== 'string' || pat.token === '' || !patId) {
384
+ throw new Error(`ZITADEL returned no token for machine user ${userId}`);
385
+ }
386
+ return { token: pat.token, patId };
387
+ }
388
+ /**
389
+ * The directory user's roles in one organisation: {@link ORG_DIRECTORY_ROLES}, set whether it is a member
390
+ * yet or not (a 409 on the add means it is, and the roles are then replaced with a `PUT`). Calls follow
391
+ * ZITADEL's published v1 management API and have not been run against a live instance.
392
+ */
393
+ async function ensureOrgDirectoryRoles(orgId, userId) {
394
+ try {
395
+ await call('POST', '/management/v1/orgs/me/members', { userId, roles: ORG_DIRECTORY_ROLES }, orgId);
396
+ }
397
+ catch (e) {
398
+ if (!(e instanceof ZitadelHttpError && e.status === 409))
399
+ throw e;
400
+ // The PUT replaces every role, so the roles a person added stay: ours join the existing set.
401
+ const existing = await memberRoles(orgId, userId);
402
+ await call('PUT', `/management/v1/orgs/me/members/${userId}`, { roles: [...existing, ...ORG_DIRECTORY_ROLES.filter((r) => !existing.includes(r))] }, orgId);
403
+ }
404
+ }
405
+ /** The organisation roles one user holds in an organisation. */
406
+ async function memberRoles(orgId, userId) {
407
+ const members = await call('POST', '/management/v1/orgs/me/members/_search', { queries: [{ userIdQuery: { userId } }] }, orgId);
408
+ return (members.result ?? [])
409
+ .filter((m) => m.userId === userId)
410
+ .flatMap((m) => m.roles ?? []);
411
+ }
332
412
  /** Deletes every PAT of a user; a PAT already gone (404) is the outcome wanted. */
333
413
  async function revokeAllPats(orgId, userId) {
334
414
  for (const patId of await listPats(orgId, userId)) {
@@ -342,7 +422,10 @@ export function createZitadelAdminMinter(options) {
342
422
  }
343
423
  }
344
424
  return {
425
+ ensureRegistrarUser,
426
+ mintRegistrarToken,
345
427
  mintOrgDirectoryReader,
428
+ ensureOrgDirectoryRoles,
346
429
  revokeOrgDirectoryReader: revokeAllPats,
347
430
  mintAdminMachineUser: (orgId, input) => mintMachineUser(orgId, input, 'IAM_OWNER').then(({ userId, token }) => ({ userId, token })),
348
431
  mintLoginClientMachineUser: (orgId, input) => mintMachineUser(orgId, input, 'IAM_LOGIN_CLIENT'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.71.0",
3
+ "version": "0.72.1",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -54,7 +54,7 @@
54
54
  "status": "node scripts/status.mjs"
55
55
  },
56
56
  "peerDependencies": {
57
- "@wtfalch/providers": ">=0.12.0",
57
+ "@wtfalch/providers": ">=0.16.0",
58
58
  "drizzle-orm": ">=0.45.2"
59
59
  },
60
60
  "dependencies": {
@@ -63,7 +63,7 @@
63
63
  "@wtfalch/auth": "^0.26.0",
64
64
  "@wtfalch/edge": "0.23.0",
65
65
  "@wtfalch/email": "^0.20.0",
66
- "@wtfalch/providers": "0.12.0",
66
+ "@wtfalch/providers": "0.16.0",
67
67
  "bcryptjs": "3.0.3",
68
68
  "ssh2": "1.17.0",
69
69
  "zod": "4.6.5"