@wtfalch/foundry 0.5.0 → 0.6.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.
Files changed (42) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +16 -1
  2. package/dist/bootstrap/bootstrap.js +40 -2
  3. package/dist/bootstrap/declare.js +6 -1
  4. package/dist/calls-sfu-deploy-support.d.ts +8 -0
  5. package/dist/calls-sfu-deploy-support.js +10 -1
  6. package/dist/calls-sfu.d.ts +31 -8
  7. package/dist/calls-sfu.js +36 -5
  8. package/dist/index.d.ts +8 -2
  9. package/dist/index.js +5 -2
  10. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  11. package/dist/migrations/0020_mail_apps.sql +7 -0
  12. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  13. package/dist/org.d.ts +81 -0
  14. package/dist/org.js +88 -0
  15. package/dist/plan/diff.js +93 -10
  16. package/dist/plan/executor.d.ts +236 -35
  17. package/dist/plan/executor.js +274 -76
  18. package/dist/plan/index.d.ts +7 -1
  19. package/dist/plan/index.js +4 -1
  20. package/dist/plan/mail-copy.d.ts +125 -0
  21. package/dist/plan/mail-copy.js +166 -0
  22. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  23. package/dist/plan/mail-identity-allowlist.js +11 -0
  24. package/dist/plan/mail-identity.d.ts +68 -0
  25. package/dist/plan/mail-identity.js +17 -0
  26. package/dist/plan/plan-apply.d.ts +5 -0
  27. package/dist/plan/platform-diff.d.ts +184 -9
  28. package/dist/plan/platform-diff.js +368 -15
  29. package/dist/plan/platform-org.d.ts +3 -2
  30. package/dist/plan/platform-providers.d.ts +138 -24
  31. package/dist/plan/platform-providers.js +1460 -79
  32. package/dist/plan/platform-sfu.d.ts +54 -19
  33. package/dist/plan/platform-sfu.js +64 -22
  34. package/dist/plan/zitadel-machine-user.d.ts +60 -11
  35. package/dist/plan/zitadel-machine-user.js +182 -39
  36. package/dist/requested-changes.d.ts +17 -7
  37. package/dist/requested-changes.js +37 -7
  38. package/dist/store/postgres.js +31 -2
  39. package/dist/store/schema.d.ts +72 -0
  40. package/dist/store/schema.js +13 -0
  41. package/dist/store.d.ts +7 -0
  42. package/package.json +1 -1
@@ -10,12 +10,21 @@
10
10
  * host key fingerprint. A human stores these as held keys ahead of time
11
11
  * (#170's "Human steps") -- this package only ever reads them back, the
12
12
  * same way #122's original script did from the environment.
13
- * - {@link CallsSfuKeysPort}: the LiveKit API key/secret pair. Unlike the
14
- * connection details, this step *mints* the pair itself, the first time,
15
- * and delivers it to held keys -- every apply after that reads the same
16
- * pair back rather than minting a second one. `executor.ts`'s
17
- * `CallsSfuPort.resolveCredentials` (the org-level calls-matrix step) reads
18
- * the same held-keys entry this delivers.
13
+ * - {@link CallsSfuKeysPort}: a LiveKit API key/secret pair *per org*, as of
14
+ * #152 ("one shared LiveKit SFU, kept apart by per-company keys", estate
15
+ * DECISIONS.md 2026-09-28). `applyCallsSfuPlatformStep` takes the list of
16
+ * org slugs with calls enabled -- `platform-providers.ts`'s `executeSfu`
17
+ * builds it from `DeclaredOrgStore.list()` -- and, for each one, mints a
18
+ * pair the first time and delivers it to held keys; every apply after that
19
+ * reads the same pair back rather than minting a second one. A slug no
20
+ * longer in the list simply contributes no line to the next apply's
21
+ * `keys:` map (`../calls-sfu.ts`'s `livekitConfigYaml`) -- its held pair is
22
+ * left stored, not deleted, the same "park, don't delete" rule the rest of
23
+ * this planner follows. `executor.ts`'s `CallsSfuPort.resolveCredentials`
24
+ * (the org-level calls-service step) reads a specific org's own held pair
25
+ * back, by that org's slug. {@link PLATFORM_LIVEKIT_KEY_SLUG}'s own pair is
26
+ * always included too, even with zero orgs enabled, so `keys:` is never
27
+ * empty (round 2 of #152's review, finding 2).
19
28
  *
20
29
  * {@link CallsSfuSshConnector} is the third port: opening the actual SSH
21
30
  * session. The real one is `calls-sfu-ssh-client.ts`'s `connectCallsSfuSsh`
@@ -48,17 +57,37 @@ export interface CallsSfuConnectionPort {
48
57
  resolveConnection(): Promise<CallsSfuConnection | null>;
49
58
  }
50
59
  /**
51
- * Where this step mints and stores the LiveKit API key/secret pair, as held
52
- * keys. `resolveLivekitKeys` returns `null` before the first apply;
53
- * `deliverLivekitKeys` is called exactly once, the first time this step
54
- * finds nothing stored -- mirrors `FoundryStepExecutorPorts.deliverSecret`'s
55
- * own "mint once, deliver, never re-mint" shape (`zitadel:app`'s
60
+ * Where this step mints and stores each org's own LiveKit API key/secret
61
+ * pair, as held keys, keyed by the org's slug (#152). `resolveLivekitKeys`
62
+ * returns `null` for a slug with no pair stored yet; `deliverLivekitKeys` is
63
+ * called exactly once per slug, the first time that slug's pair comes back
64
+ * `null` -- mirrors `FoundryStepExecutorPorts.deliverSecret`'s own "mint
65
+ * once, deliver, never re-mint" shape (`zitadel:app`'s
56
66
  * `createApiApp`/`deliverSecret` pair in `executor.ts`).
67
+ *
68
+ * `deliverLivekitKeys` must be create-if-absent at the store: the first
69
+ * write for a slug wins and a later one is a no-op, not an overwrite. Two
70
+ * concurrent applies can both see `resolveLivekitKeys` return `null` for the
71
+ * same new slug and each mint a different pair; create-if-absent is what
72
+ * lets {@link applyCallsSfuPlatformStep}'s post-deliver re-read (round 2 of
73
+ * #152's review) converge both applies on whichever pair the store actually
74
+ * kept, rather than each one writing its own locally-minted pair into
75
+ * `livekit.yaml`.
57
76
  */
58
77
  export interface CallsSfuKeysPort {
59
- resolveLivekitKeys(): Promise<LivekitCredentials | null>;
60
- deliverLivekitKeys(pair: LivekitCredentials): Promise<void>;
78
+ resolveLivekitKeys(orgSlug: string): Promise<LivekitCredentials | null>;
79
+ deliverLivekitKeys(orgSlug: string, pair: LivekitCredentials): Promise<void>;
61
80
  }
81
+ /**
82
+ * The key id the platform's own LiveKit pair is held under, alongside every
83
+ * org's own pair, in the same {@link CallsSfuKeysPort} (#152 round 2 review:
84
+ * a `keys:` map with no entries at all makes LiveKit refuse to start outside
85
+ * dev mode, so an estate with no org's calls turned on yet still needs one
86
+ * live key). `oikos` is reserved and can never be a real org's own slug
87
+ * (`../slug.ts`'s `RESERVED_SLUGS`), so this can never collide with an org's
88
+ * pair.
89
+ */
90
+ export declare const PLATFORM_LIVEKIT_KEY_SLUG = "oikos";
62
91
  /** Opens the actual SSH session; `connectCallsSfuSsh` is the real implementation. */
63
92
  export interface CallsSfuSshConnector {
64
93
  connect(options: {
@@ -83,11 +112,17 @@ export interface CallsSfuPlatformDeps {
83
112
  mintLivekitCredentials?: () => LivekitCredentials;
84
113
  }
85
114
  /**
86
- * Runs the platform's `sfu` step: resolve the connection, mint the LiveKit
87
- * pair the first time (read it back on every apply after that), open the
88
- * SSH session, and apply. Every part of this is safe to re-run -- minting
89
- * only happens once the stored pair comes back `null`, and
115
+ * Runs the platform's `sfu` step: resolve the connection, mint each enabled
116
+ * org's own LiveKit pair the first time (read it back on every apply after
117
+ * that), open the SSH session, and apply. `orgSlugs` is every org with calls
118
+ * enabled, in no required order -- sorted here, alongside the always-present
119
+ * {@link PLATFORM_LIVEKIT_KEY_SLUG}, so a re-run with the same set mints
120
+ * nothing new and builds the identical `keys:` map (`../calls-sfu.ts`'s
121
+ * `livekitConfigYaml` sorts it again regardless, but sorting before minting
122
+ * keeps this function's own behaviour independent of the order its caller
123
+ * happens to list orgs in). Every part of this is safe to re-run -- minting
124
+ * only happens once a given slug's stored pair comes back `null`, and
90
125
  * `applyCallsSfuOverSession` is itself idempotent -- so a second apply with
91
- * the same held keys changes nothing.
126
+ * the same held keys and the same org list changes nothing.
92
127
  */
93
- export declare function applyCallsSfuPlatformStep(deps: CallsSfuPlatformDeps): Promise<void>;
128
+ export declare function applyCallsSfuPlatformStep(deps: CallsSfuPlatformDeps, orgSlugs?: readonly string[]): Promise<void>;
@@ -10,12 +10,21 @@
10
10
  * host key fingerprint. A human stores these as held keys ahead of time
11
11
  * (#170's "Human steps") -- this package only ever reads them back, the
12
12
  * same way #122's original script did from the environment.
13
- * - {@link CallsSfuKeysPort}: the LiveKit API key/secret pair. Unlike the
14
- * connection details, this step *mints* the pair itself, the first time,
15
- * and delivers it to held keys -- every apply after that reads the same
16
- * pair back rather than minting a second one. `executor.ts`'s
17
- * `CallsSfuPort.resolveCredentials` (the org-level calls-matrix step) reads
18
- * the same held-keys entry this delivers.
13
+ * - {@link CallsSfuKeysPort}: a LiveKit API key/secret pair *per org*, as of
14
+ * #152 ("one shared LiveKit SFU, kept apart by per-company keys", estate
15
+ * DECISIONS.md 2026-09-28). `applyCallsSfuPlatformStep` takes the list of
16
+ * org slugs with calls enabled -- `platform-providers.ts`'s `executeSfu`
17
+ * builds it from `DeclaredOrgStore.list()` -- and, for each one, mints a
18
+ * pair the first time and delivers it to held keys; every apply after that
19
+ * reads the same pair back rather than minting a second one. A slug no
20
+ * longer in the list simply contributes no line to the next apply's
21
+ * `keys:` map (`../calls-sfu.ts`'s `livekitConfigYaml`) -- its held pair is
22
+ * left stored, not deleted, the same "park, don't delete" rule the rest of
23
+ * this planner follows. `executor.ts`'s `CallsSfuPort.resolveCredentials`
24
+ * (the org-level calls-service step) reads a specific org's own held pair
25
+ * back, by that org's slug. {@link PLATFORM_LIVEKIT_KEY_SLUG}'s own pair is
26
+ * always included too, even with zero orgs enabled, so `keys:` is never
27
+ * empty (round 2 of #152's review, finding 2).
19
28
  *
20
29
  * {@link CallsSfuSshConnector} is the third port: opening the actual SSH
21
30
  * session. The real one is `calls-sfu-ssh-client.ts`'s `connectCallsSfuSsh`
@@ -29,6 +38,16 @@
29
38
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
30
39
  import { mintLivekitCredentials } from '../calls-sfu-keys.js';
31
40
  import { applyCallsSfuOverSession } from '../calls-sfu-ssh-adapter.js';
41
+ /**
42
+ * The key id the platform's own LiveKit pair is held under, alongside every
43
+ * org's own pair, in the same {@link CallsSfuKeysPort} (#152 round 2 review:
44
+ * a `keys:` map with no entries at all makes LiveKit refuse to start outside
45
+ * dev mode, so an estate with no org's calls turned on yet still needs one
46
+ * live key). `oikos` is reserved and can never be a real org's own slug
47
+ * (`../slug.ts`'s `RESERVED_SLUGS`), so this can never collide with an org's
48
+ * pair.
49
+ */
50
+ export const PLATFORM_LIVEKIT_KEY_SLUG = 'oikos';
32
51
  export class MissingCallsSfuConnectionError extends Error {
33
52
  constructor() {
34
53
  super('No SSH connection details are held for the calls SFU. Store the SFU VM’s ' +
@@ -38,14 +57,20 @@ export class MissingCallsSfuConnectionError extends Error {
38
57
  }
39
58
  }
40
59
  /**
41
- * Runs the platform's `sfu` step: resolve the connection, mint the LiveKit
42
- * pair the first time (read it back on every apply after that), open the
43
- * SSH session, and apply. Every part of this is safe to re-run -- minting
44
- * only happens once the stored pair comes back `null`, and
60
+ * Runs the platform's `sfu` step: resolve the connection, mint each enabled
61
+ * org's own LiveKit pair the first time (read it back on every apply after
62
+ * that), open the SSH session, and apply. `orgSlugs` is every org with calls
63
+ * enabled, in no required order -- sorted here, alongside the always-present
64
+ * {@link PLATFORM_LIVEKIT_KEY_SLUG}, so a re-run with the same set mints
65
+ * nothing new and builds the identical `keys:` map (`../calls-sfu.ts`'s
66
+ * `livekitConfigYaml` sorts it again regardless, but sorting before minting
67
+ * keeps this function's own behaviour independent of the order its caller
68
+ * happens to list orgs in). Every part of this is safe to re-run -- minting
69
+ * only happens once a given slug's stored pair comes back `null`, and
45
70
  * `applyCallsSfuOverSession` is itself idempotent -- so a second apply with
46
- * the same held keys changes nothing.
71
+ * the same held keys and the same org list changes nothing.
47
72
  */
48
- export async function applyCallsSfuPlatformStep(deps) {
73
+ export async function applyCallsSfuPlatformStep(deps, orgSlugs = []) {
49
74
  const connection = await deps.connection.resolveConnection();
50
75
  if (!connection)
51
76
  throw new MissingCallsSfuConnectionError();
@@ -54,14 +79,32 @@ export async function applyCallsSfuPlatformStep(deps) {
54
79
  // it is redacted before it leaves this function.
55
80
  const secrets = [connection.sshPrivateKey, connection.cloudflaredTunnelToken];
56
81
  try {
57
- let pair = await deps.keys.resolveLivekitKeys();
58
- if (!pair) {
59
- pair = (deps.mintLivekitCredentials ?? mintLivekitCredentials)();
60
- secrets.push(pair.apiKey, pair.apiSecret);
61
- await deps.keys.deliverLivekitKeys(pair);
62
- }
63
- else {
64
- secrets.push(pair.apiKey, pair.apiSecret);
82
+ const livekitKeys = {};
83
+ const slugs = new Set([PLATFORM_LIVEKIT_KEY_SLUG, ...orgSlugs]);
84
+ for (const orgSlug of [...slugs].sort()) {
85
+ let pair = await deps.keys.resolveLivekitKeys(orgSlug);
86
+ if (!pair) {
87
+ const minted = (deps.mintLivekitCredentials ?? mintLivekitCredentials)();
88
+ // Pushed before the delivery call below (not after): a delivery
89
+ // that throws with the freshly minted secret in its message must
90
+ // still find it in this list to redact it (round 2 of #152's
91
+ // review, finding 1).
92
+ secrets.push(minted.apiKey, minted.apiSecret);
93
+ await deps.keys.deliverLivekitKeys(orgSlug, minted);
94
+ // `deliverLivekitKeys` is create-if-absent (this interface's own
95
+ // doc comment), so a concurrent apply that minted first may already
96
+ // have won the write for this slug. Re-read rather than trusting
97
+ // the locally minted pair, so this apply's own `livekit.yaml`
98
+ // always matches whatever the store actually kept (round 2's
99
+ // finding 3).
100
+ pair = (await deps.keys.resolveLivekitKeys(orgSlug)) ?? minted;
101
+ if (pair !== minted)
102
+ secrets.push(pair.apiKey, pair.apiSecret);
103
+ }
104
+ else {
105
+ secrets.push(pair.apiKey, pair.apiSecret);
106
+ }
107
+ livekitKeys[pair.apiKey] = pair.apiSecret;
65
108
  }
66
109
  const session = await deps.ssh.connect({
67
110
  address: connection.address,
@@ -73,8 +116,7 @@ export async function applyCallsSfuPlatformStep(deps) {
73
116
  try {
74
117
  const config = {
75
118
  address: connection.address,
76
- livekitApiKey: pair.apiKey,
77
- livekitApiSecret: pair.apiSecret,
119
+ livekitKeys,
78
120
  cloudflaredTunnelToken: connection.cloudflaredTunnelToken,
79
121
  };
80
122
  await applyCallsSfuOverSession(session, config, connection.sshHostFingerprint);
@@ -1,18 +1,46 @@
1
1
  import type { ZitadelPort } from './executor.js';
2
2
  /**
3
- * #168: the real {@link ZitadelPort.mintAdminMachineUser}. `@wtfalch/providers`
4
- * has no machine-user support, so this speaks ZITADEL's v1 management and
5
- * admin APIs directly, with the same request shape as providers'
6
- * `ZitadelClient.call` (bearer token, JSON in and out, `x-zitadel-orgid` for
7
- * the target org, optional instance-host headers, a typed error on a non-ok
8
- * response). The calls follow ZITADEL's published API reference and have not
9
- * been run against a live instance.
3
+ * #168: the real {@link ZitadelPort.mintAdminMachineUser}/
4
+ * {@link ZitadelPort.mintLoginClientMachineUser}. `@wtfalch/providers` has no
5
+ * machine-user support, so this speaks ZITADEL's v1 management and admin APIs
6
+ * directly, with the same request shape as providers' `ZitadelClient.call`
7
+ * (bearer token, JSON in and out, `x-zitadel-orgid` for the target org,
8
+ * optional instance-host headers, a typed error on a non-ok response). The
9
+ * calls follow ZITADEL's published API reference and have not been run
10
+ * against a live instance.
10
11
  *
11
12
  * 1. `POST /management/v1/users/_search` finds the machine user by user name.
12
13
  * 2. `POST /management/v1/users/machine` creates it when absent.
13
- * 3. `POST /admin/v1/members` grants `IAM_OWNER` (409 means already granted).
14
- * 4. `POST /management/v1/users/{id}/pats` adds a token, which ZITADEL
15
- * returns once.
14
+ * 3. #216: for the login-client role on an *existing* user, `POST
15
+ * /admin/v1/members/_search` reads its currently granted IAM roles, and
16
+ * refuses (rather than reusing) a user that holds anything other than
17
+ * exactly `IAM_LOGIN_CLIENT` -- see {@link ZitadelLoginClientRoleConflictError}.
18
+ * 4. `POST /admin/v1/members` grants the role (409 means already granted).
19
+ * 5. #216: for the login-client role on an existing user, `POST
20
+ * /management/v1/users/{id}/pats/_search` (round 2 of the second PR
21
+ * review: ZITADEL lists PATs with a POST and a JSON body, `{}` here --
22
+ * not the `GET` this file used until then, which a live instance
23
+ * rejects before the revoke sweep ever runs) lists every PAT it already
24
+ * holds, and `DELETE /management/v1/users/{id}/pats/{patId}` removes
25
+ * each one --
26
+ * before the new PAT is requested, so a rerun (a crash between a prior
27
+ * mint and the caller recording it, or every delivery retry failing)
28
+ * always leaves exactly one live token, never two. The admin role's PAT
29
+ * is never revoked this way, unchanged from before #216: nothing here
30
+ * decrypts with a stale admin PAT the way ZITADEL's own masterkey
31
+ * protects data encrypted under it, but this file still only revokes
32
+ * what the caller (`platform-providers.ts`) actually asked for. #216
33
+ * round 3: a 404 on one of these deletes (two concurrent mints listing
34
+ * and deleting the same already-gone PAT) is treated as already done,
35
+ * not an error -- the sweep's goal, that PAT is gone, already holds.
36
+ * 6. `POST /management/v1/users/{id}/pats` adds a token, which ZITADEL
37
+ * returns once, alongside the new PAT's own id (`tokenId`) -- #216
38
+ * round 2: {@link ZitadelPort.mintLoginClientMachineUser} returns that
39
+ * id (`patId`) so the caller can re-list PATs after delivery and detect
40
+ * a concurrent apply that revoked or replaced it (see
41
+ * {@link ZitadelPort.listLoginClientPats}'s own doc comment). The admin
42
+ * role's mint drops it before returning, unchanged from before #216
43
+ * round 2 -- nothing reads it there.
16
44
  *
17
45
  * `token` is the bearer for the new instance, so it must belong to a caller
18
46
  * allowed to create users and instance members (for example the instance's
@@ -23,6 +51,27 @@ export declare class ZitadelHttpError extends Error {
23
51
  readonly status: number;
24
52
  constructor(status: number, message: string);
25
53
  }
54
+ /**
55
+ * #216: thrown by {@link createZitadelAdminMinter}'s `mintLoginClientMachineUser`
56
+ * when a `foundry-login-client` user already exists but holds an IAM role
57
+ * other than exactly `IAM_LOGIN_CLIENT` -- for example `IAM_OWNER`. That user
58
+ * was not created by this method; granting it an additional role, or handing
59
+ * its PAT out as the login-client token, would widen access nobody asked
60
+ * for. Never caught and retried -- a human has to resolve the conflicting
61
+ * user by hand.
62
+ *
63
+ * #216 round 2: a user with *no* role at all is never refused this way --
64
+ * see `mintMachineUser`'s own `roles.length > 0 && ...` check. That is the
65
+ * crash window between `POST /management/v1/users/machine` creating the user
66
+ * and `POST /admin/v1/members` granting it the role; refusing it would turn
67
+ * an ordinary crash-and-rerun into a permanent, human-only block, which is
68
+ * exactly the bug class #216 exists to close.
69
+ */
70
+ export declare class ZitadelLoginClientRoleConflictError extends Error {
71
+ readonly userId: string;
72
+ readonly roles: readonly string[];
73
+ constructor(userId: string, roles: readonly string[]);
74
+ }
26
75
  export interface ZitadelMinterOptions {
27
76
  /** The new instance's base URL, e.g. `https://auth.companyincluded.com`. */
28
77
  url: string;
@@ -31,4 +80,4 @@ export interface ZitadelMinterOptions {
31
80
  fetchImpl?: typeof fetch;
32
81
  instanceHost?: string;
33
82
  }
34
- export declare function createZitadelAdminMinter(options: ZitadelMinterOptions): Pick<ZitadelPort, 'mintAdminMachineUser'>;
83
+ export declare function createZitadelAdminMinter(options: ZitadelMinterOptions): Pick<ZitadelPort, 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats'>;
@@ -1,17 +1,45 @@
1
1
  /**
2
- * #168: the real {@link ZitadelPort.mintAdminMachineUser}. `@wtfalch/providers`
3
- * has no machine-user support, so this speaks ZITADEL's v1 management and
4
- * admin APIs directly, with the same request shape as providers'
5
- * `ZitadelClient.call` (bearer token, JSON in and out, `x-zitadel-orgid` for
6
- * the target org, optional instance-host headers, a typed error on a non-ok
7
- * response). The calls follow ZITADEL's published API reference and have not
8
- * been run against a live instance.
2
+ * #168: the real {@link ZitadelPort.mintAdminMachineUser}/
3
+ * {@link ZitadelPort.mintLoginClientMachineUser}. `@wtfalch/providers` has no
4
+ * machine-user support, so this speaks ZITADEL's v1 management and admin APIs
5
+ * directly, with the same request shape as providers' `ZitadelClient.call`
6
+ * (bearer token, JSON in and out, `x-zitadel-orgid` for the target org,
7
+ * optional instance-host headers, a typed error on a non-ok response). The
8
+ * calls follow ZITADEL's published API reference and have not been run
9
+ * against a live instance.
9
10
  *
10
11
  * 1. `POST /management/v1/users/_search` finds the machine user by user name.
11
12
  * 2. `POST /management/v1/users/machine` creates it when absent.
12
- * 3. `POST /admin/v1/members` grants `IAM_OWNER` (409 means already granted).
13
- * 4. `POST /management/v1/users/{id}/pats` adds a token, which ZITADEL
14
- * returns once.
13
+ * 3. #216: for the login-client role on an *existing* user, `POST
14
+ * /admin/v1/members/_search` reads its currently granted IAM roles, and
15
+ * refuses (rather than reusing) a user that holds anything other than
16
+ * exactly `IAM_LOGIN_CLIENT` -- see {@link ZitadelLoginClientRoleConflictError}.
17
+ * 4. `POST /admin/v1/members` grants the role (409 means already granted).
18
+ * 5. #216: for the login-client role on an existing user, `POST
19
+ * /management/v1/users/{id}/pats/_search` (round 2 of the second PR
20
+ * review: ZITADEL lists PATs with a POST and a JSON body, `{}` here --
21
+ * not the `GET` this file used until then, which a live instance
22
+ * rejects before the revoke sweep ever runs) lists every PAT it already
23
+ * holds, and `DELETE /management/v1/users/{id}/pats/{patId}` removes
24
+ * each one --
25
+ * before the new PAT is requested, so a rerun (a crash between a prior
26
+ * mint and the caller recording it, or every delivery retry failing)
27
+ * always leaves exactly one live token, never two. The admin role's PAT
28
+ * is never revoked this way, unchanged from before #216: nothing here
29
+ * decrypts with a stale admin PAT the way ZITADEL's own masterkey
30
+ * protects data encrypted under it, but this file still only revokes
31
+ * what the caller (`platform-providers.ts`) actually asked for. #216
32
+ * round 3: a 404 on one of these deletes (two concurrent mints listing
33
+ * and deleting the same already-gone PAT) is treated as already done,
34
+ * not an error -- the sweep's goal, that PAT is gone, already holds.
35
+ * 6. `POST /management/v1/users/{id}/pats` adds a token, which ZITADEL
36
+ * returns once, alongside the new PAT's own id (`tokenId`) -- #216
37
+ * round 2: {@link ZitadelPort.mintLoginClientMachineUser} returns that
38
+ * id (`patId`) so the caller can re-list PATs after delivery and detect
39
+ * a concurrent apply that revoked or replaced it (see
40
+ * {@link ZitadelPort.listLoginClientPats}'s own doc comment). The admin
41
+ * role's mint drops it before returning, unchanged from before #216
42
+ * round 2 -- nothing reads it there.
15
43
  *
16
44
  * `token` is the bearer for the new instance, so it must belong to a caller
17
45
  * allowed to create users and instance members (for example the instance's
@@ -26,6 +54,32 @@ export class ZitadelHttpError extends Error {
26
54
  this.name = 'ZitadelHttpError';
27
55
  }
28
56
  }
57
+ /**
58
+ * #216: thrown by {@link createZitadelAdminMinter}'s `mintLoginClientMachineUser`
59
+ * when a `foundry-login-client` user already exists but holds an IAM role
60
+ * other than exactly `IAM_LOGIN_CLIENT` -- for example `IAM_OWNER`. That user
61
+ * was not created by this method; granting it an additional role, or handing
62
+ * its PAT out as the login-client token, would widen access nobody asked
63
+ * for. Never caught and retried -- a human has to resolve the conflicting
64
+ * user by hand.
65
+ *
66
+ * #216 round 2: a user with *no* role at all is never refused this way --
67
+ * see `mintMachineUser`'s own `roles.length > 0 && ...` check. That is the
68
+ * crash window between `POST /management/v1/users/machine` creating the user
69
+ * and `POST /admin/v1/members` granting it the role; refusing it would turn
70
+ * an ordinary crash-and-rerun into a permanent, human-only block, which is
71
+ * exactly the bug class #216 exists to close.
72
+ */
73
+ export class ZitadelLoginClientRoleConflictError extends Error {
74
+ userId;
75
+ roles;
76
+ constructor(userId, roles) {
77
+ super(`ZITADEL machine user ${userId} already exists with role(s) [${roles.join(', ') || 'none'}], not [IAM_LOGIN_CLIENT] -- refusing to reuse it as foundry's login-client user`);
78
+ this.userId = userId;
79
+ this.roles = roles;
80
+ this.name = 'ZitadelLoginClientRoleConflictError';
81
+ }
82
+ }
29
83
  export function createZitadelAdminMinter(options) {
30
84
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
31
85
  async function call(method, path, body, orgId) {
@@ -59,37 +113,126 @@ export function createZitadelAdminMinter(options) {
59
113
  }
60
114
  return data;
61
115
  }
62
- return {
63
- async mintAdminMachineUser(orgId, input) {
64
- const found = await call('POST', '/management/v1/users/_search', {
65
- queries: [
66
- { userNameQuery: { userName: input.userName, method: 'TEXT_QUERY_METHOD_EQUALS' } },
67
- ],
116
+ /**
117
+ * #216 round 2: every live PAT id on a machine user, for both the
118
+ * revoke-before-mint sweep below and {@link ZitadelPort.listLoginClientPats}
119
+ * -- one call, one place that reads it.
120
+ *
121
+ * #216 (second PR review, round 2): ZITADEL lists a user's PATs with `POST
122
+ * /management/v1/users/{id}/pats/_search` and a JSON body (`{}` -- no
123
+ * filter narrows the search), not `GET` on the plain collection route this
124
+ * function used until this fix. A `GET` here 404s/405s before the
125
+ * revoke-before-mint sweep below ever runs on a live instance, so a re-mint
126
+ * on an existing login-client user never got as far as revoking its old
127
+ * PAT; `authLoginClientDrifted` (`platform-providers.ts`) then swallowed
128
+ * that failure as "unknown, not drifted" (its own `try`/`catch`), silently
129
+ * masking the break.
130
+ */
131
+ async function listPats(orgId, userId) {
132
+ const pats = await call('POST', `/management/v1/users/${userId}/pats/_search`, {}, orgId);
133
+ return (pats.result ?? [])
134
+ .map((p) => p.id)
135
+ .filter((id) => Boolean(id));
136
+ }
137
+ /**
138
+ * Shared by `mintAdminMachineUser` (`role: 'IAM_OWNER'`) and
139
+ * `mintLoginClientMachineUser` (`role: 'IAM_LOGIN_CLIENT'`) -- find or
140
+ * create the machine user by `userName`, grant `role`, then mint a fresh
141
+ * PAT. See this file's own top doc comment for the numbered call sequence
142
+ * and which of the `#216` steps (3 and 5) apply only to the login-client
143
+ * role, on an existing user, to keep the admin path's own behaviour
144
+ * unchanged.
145
+ */
146
+ async function mintMachineUser(orgId, input, role) {
147
+ const found = await call('POST', '/management/v1/users/_search', {
148
+ queries: [
149
+ { userNameQuery: { userName: input.userName, method: 'TEXT_QUERY_METHOD_EQUALS' } },
150
+ ],
151
+ }, orgId);
152
+ const existing = found.result?.[0]?.id;
153
+ let userId = existing;
154
+ if (!userId) {
155
+ const created = await call('POST', '/management/v1/users/machine', {
156
+ userName: input.userName,
157
+ name: input.name,
158
+ accessTokenType: 'ACCESS_TOKEN_TYPE_BEARER',
68
159
  }, orgId);
69
- const existing = found.result?.[0]?.id;
70
- let userId = existing;
71
- if (!userId) {
72
- const created = await call('POST', '/management/v1/users/machine', {
73
- userName: input.userName,
74
- name: input.name,
75
- accessTokenType: 'ACCESS_TOKEN_TYPE_BEARER',
76
- }, orgId);
77
- userId = typeof created.userId === 'string' ? created.userId : undefined;
78
- }
79
- if (!userId)
80
- throw new Error('ZITADEL did not return the machine user id');
81
- try {
82
- await call('POST', '/admin/v1/members', { userId, roles: ['IAM_OWNER'] });
83
- }
84
- catch (e) {
85
- if (!(e instanceof ZitadelHttpError && e.status === 409))
86
- throw e;
160
+ userId = typeof created.userId === 'string' ? created.userId : undefined;
161
+ }
162
+ if (!userId)
163
+ throw new Error('ZITADEL did not return the machine user id');
164
+ // #216: only the login-client role checks an existing user's roles
165
+ // before reusing it -- see this function's own doc comment for why the
166
+ // admin path stays as it was.
167
+ if (existing && role === 'IAM_LOGIN_CLIENT') {
168
+ const members = await call('POST', '/admin/v1/members/_search', {
169
+ queries: [{ userIdQuery: { userId } }],
170
+ });
171
+ const roles = (members.result ?? [])
172
+ .filter((m) => m.userId === userId)
173
+ .flatMap((m) => m.roles ?? []);
174
+ if (roles.length > 0 && !(roles.length === 1 && roles[0] === role)) {
175
+ throw new ZitadelLoginClientRoleConflictError(userId, roles);
87
176
  }
88
- const pat = await call('POST', `/management/v1/users/${userId}/pats`, {}, orgId);
89
- if (typeof pat.token !== 'string' || pat.token === '') {
90
- throw new Error(`ZITADEL returned no token for machine user ${userId}`);
177
+ }
178
+ try {
179
+ await call('POST', '/admin/v1/members', { userId, roles: [role] });
180
+ }
181
+ catch (e) {
182
+ if (!(e instanceof ZitadelHttpError && e.status === 409))
183
+ throw e;
184
+ }
185
+ // #216 (third PR review): true exactly when a live PAT existed on this
186
+ // user and this sweep revoked it -- never true on a first-ever mint
187
+ // (`existing` false, or an admin-role mint, which never revokes). The
188
+ // caller (`provisionPlatformAuth`) uses this to word a delivery-failure
189
+ // error correctly: revoking-then-failing leaves the apps with no live
190
+ // token at all, where a first mint that fails to deliver never had one.
191
+ let revokedPriorPat = false;
192
+ if (existing && role === 'IAM_LOGIN_CLIENT') {
193
+ // #216: revoke every PAT already on the user before minting a new one
194
+ // -- ZITADEL shows a PAT's value once, so a crash or a failed
195
+ // delivery between a prior mint and the caller recording it would
196
+ // otherwise leave that lost token live forever, and a rerun would
197
+ // mint a second one alongside it rather than replacing it.
198
+ const existingPats = await listPats(orgId, userId);
199
+ revokedPriorPat = existingPats.length > 0;
200
+ for (const patId of existingPats) {
201
+ try {
202
+ await call('DELETE', `/management/v1/users/${userId}/pats/${patId}`, undefined, orgId);
203
+ }
204
+ catch (e) {
205
+ // #216 round 3: two concurrent mints can both list the same
206
+ // existing PAT and both try to delete it -- the second DELETE
207
+ // finds it already gone. That is the outcome this sweep wants,
208
+ // not a failure; only a non-404 error (ZITADEL unreachable, a
209
+ // permissions problem) should still abort the mint.
210
+ if (!(e instanceof ZitadelHttpError && e.status === 404))
211
+ throw e;
212
+ }
91
213
  }
92
- return { userId, token: pat.token };
93
- },
214
+ }
215
+ const pat = await call('POST', `/management/v1/users/${userId}/pats`, {}, orgId);
216
+ if (typeof pat.token !== 'string' || pat.token === '') {
217
+ throw new Error(`ZITADEL returned no token for machine user ${userId}`);
218
+ }
219
+ const patId = typeof pat.tokenId === 'string' && pat.tokenId !== '' ? pat.tokenId : undefined;
220
+ // Only the login-client role's caller ever reads `patId` (the round-2
221
+ // concurrent-apply guard) -- the admin path never required a PAT id
222
+ // before #216 round 2 and still does not, so this never turns a response
223
+ // the admin path already accepted into a new failure.
224
+ if (role === 'IAM_LOGIN_CLIENT' && !patId) {
225
+ throw new Error(`ZITADEL returned no token id for machine user ${userId}`);
226
+ }
227
+ return { userId, token: pat.token, patId: patId ?? '', revokedPriorPat };
228
+ }
229
+ return {
230
+ mintAdminMachineUser: (orgId, input) => mintMachineUser(orgId, input, 'IAM_OWNER').then(({ userId, token }) => ({ userId, token })),
231
+ mintLoginClientMachineUser: (orgId, input) => mintMachineUser(orgId, input, 'IAM_LOGIN_CLIENT'),
232
+ // #216 round 2: lets the caller (`platform-providers.ts`) re-check, after
233
+ // delivering a freshly minted token, that it is still live and alone on
234
+ // the user -- see `ZitadelPort.listLoginClientPats`'s own doc comment for
235
+ // the race this closes.
236
+ listLoginClientPats: (orgId, userId) => listPats(orgId, userId),
94
237
  };
95
238
  }
@@ -11,7 +11,9 @@ import type { PlanDetail } from './plan/diff.js';
11
11
  * and safe; `domain` and `mailTier` are full self-service too, but both are
12
12
  * migrating steps (`plan/diff.ts`'s `planMail` moves live mail between
13
13
  * modes; a domain move is at least as disruptive) and always need approval,
14
- * never auto.
14
+ * never auto. #184 narrows `storageLimit`/`aiLimit`'s "small and safe" to
15
+ * lowering or leaving a limit unchanged -- raising either one needs
16
+ * approval too, until a billing field exists to say what the raise costs.
15
17
  */
16
18
  export type RequestableField = 'calls' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier';
17
19
  export declare const REQUESTABLE_FIELDS: readonly RequestableField[];
@@ -85,12 +87,20 @@ export declare class RequestedChangeError extends Error {
85
87
  constructor(message: string);
86
88
  }
87
89
  /**
88
- * `auto` when `change` is small and safe (`calls`, `storageLimit`,
89
- * `aiLimit`), the plan it causes has no destructive or migrating step, and
90
- * it causes no price change on `org`'s own billing fields; `approval`
91
- * otherwise -- every `domain` or `mailTier` change classifies `approval`
92
- * unconditionally, since #178 "Decide" calls both migrating steps that
93
- * always need approval, whatever direction the change moves.
90
+ * `auto` when `change` is small and safe (`calls`, and `storageLimit`/
91
+ * `aiLimit` in the lowering or unchanged direction), the plan it causes has
92
+ * no destructive or migrating step, and it causes no price change on
93
+ * `org`'s own billing fields; `approval` otherwise -- every `domain` or
94
+ * `mailTier` change classifies `approval` unconditionally, since #178
95
+ * "Decide" calls both migrating steps that always need approval, whatever
96
+ * direction the change moves.
97
+ *
98
+ * A `storageLimit` or `aiLimit` change that *raises* either limit
99
+ * ({@link isLimitRaise} against `org`'s current value) classifies
100
+ * `approval`, decided in #184: "a raise of the storage or AI limit
101
+ * classifies `approval` until billing fields exist. A lowering stays
102
+ * `auto`." `storageLimit` carries two independent limits (`limitBytes`,
103
+ * `limitMicros`); a raise on either one is enough.
94
104
  *
95
105
  * `plan` is the {@link PlanDetail} `change` causes -- the caller's own diff
96
106
  * against live reality, from the same `computePlanDetail` (`plan/diff.ts`)