@wtfalch/foundry 0.4.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 (76) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +111 -0
  2. package/dist/bootstrap/bootstrap.js +323 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +99 -0
  5. package/dist/bootstrap/drive.d.ts +40 -0
  6. package/dist/bootstrap/drive.js +51 -0
  7. package/dist/bootstrap/index.d.ts +3 -0
  8. package/dist/bootstrap/index.js +1 -0
  9. package/dist/calls-sfu-deploy-support.d.ts +17 -6
  10. package/dist/calls-sfu-deploy-support.js +19 -7
  11. package/dist/calls-sfu-keys.d.ts +21 -0
  12. package/dist/calls-sfu-keys.js +27 -0
  13. package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
  14. package/dist/calls-sfu-ssh-adapter.js +78 -0
  15. package/dist/calls-sfu-ssh-client.d.ts +49 -0
  16. package/dist/calls-sfu-ssh-client.js +82 -0
  17. package/dist/calls-sfu.d.ts +36 -12
  18. package/dist/calls-sfu.js +41 -9
  19. package/dist/index.d.ts +38 -7
  20. package/dist/index.js +20 -4
  21. package/dist/migrations/0016_platform.sql +61 -0
  22. package/dist/migrations/0017_requested_changes.sql +42 -0
  23. package/dist/migrations/0018_platform_apply.sql +19 -0
  24. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  25. package/dist/migrations/0020_mail_apps.sql +7 -0
  26. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  27. package/dist/org.d.ts +81 -0
  28. package/dist/org.js +96 -0
  29. package/dist/plan/agora.d.ts +77 -0
  30. package/dist/plan/agora.js +127 -0
  31. package/dist/plan/cloudflared-connector.d.ts +70 -0
  32. package/dist/plan/cloudflared-connector.js +106 -0
  33. package/dist/plan/diff.d.ts +20 -3
  34. package/dist/plan/diff.js +713 -115
  35. package/dist/plan/executor.d.ts +680 -172
  36. package/dist/plan/executor.js +755 -189
  37. package/dist/plan/index.d.ts +28 -5
  38. package/dist/plan/index.js +15 -2
  39. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  40. package/dist/plan/jobs-scheduler-http.js +50 -0
  41. package/dist/plan/live.d.ts +51 -2
  42. package/dist/plan/live.js +8 -0
  43. package/dist/plan/mail-copy.d.ts +125 -0
  44. package/dist/plan/mail-copy.js +166 -0
  45. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  46. package/dist/plan/mail-identity-allowlist.js +11 -0
  47. package/dist/plan/mail-identity.d.ts +68 -0
  48. package/dist/plan/mail-identity.js +17 -0
  49. package/dist/plan/plan-apply.d.ts +197 -3
  50. package/dist/plan/plan-apply.js +278 -9
  51. package/dist/plan/platform-diff.d.ts +487 -0
  52. package/dist/plan/platform-diff.js +1220 -0
  53. package/dist/plan/platform-org-ports.d.ts +50 -0
  54. package/dist/plan/platform-org-ports.js +70 -0
  55. package/dist/plan/platform-org.d.ts +121 -0
  56. package/dist/plan/platform-org.js +115 -0
  57. package/dist/plan/platform-providers.d.ts +615 -0
  58. package/dist/plan/platform-providers.js +4410 -0
  59. package/dist/plan/platform-sfu.d.ts +128 -0
  60. package/dist/plan/platform-sfu.js +142 -0
  61. package/dist/plan/providers-readers.js +22 -1
  62. package/dist/plan/zitadel-machine-user.d.ts +83 -0
  63. package/dist/plan/zitadel-machine-user.js +238 -0
  64. package/dist/plan.d.ts +6 -0
  65. package/dist/platform.d.ts +55 -0
  66. package/dist/platform.js +46 -0
  67. package/dist/requested-changes.d.ts +118 -0
  68. package/dist/requested-changes.js +125 -0
  69. package/dist/slug.js +15 -2
  70. package/dist/store/audit.js +4 -2
  71. package/dist/store/postgres.d.ts +29 -1
  72. package/dist/store/postgres.js +203 -4
  73. package/dist/store/schema.d.ts +755 -0
  74. package/dist/store/schema.js +70 -0
  75. package/dist/store.d.ts +53 -0
  76. package/package.json +1 -1
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The platform's `sfu` step (#170): the shared calls SFU (LiveKit +
3
+ * cloudflared, `../calls-sfu.ts`) applied from `applyPlatform` instead of by
4
+ * hand running `scripts/calls-sfu-deploy.mjs`. Mirrors #167's
5
+ * `platform-providers.ts` shape -- narrow ports, a host (archon) supplies the
6
+ * real implementation -- for the two things this step needs beyond
7
+ * `applyCallsSfu` itself:
8
+ *
9
+ * - {@link CallsSfuConnectionPort}: the SFU VM's address, SSH credentials and
10
+ * host key fingerprint. A human stores these as held keys ahead of time
11
+ * (#170's "Human steps") -- this package only ever reads them back, the
12
+ * same way #122's original script did from the environment.
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).
28
+ *
29
+ * {@link CallsSfuSshConnector} is the third port: opening the actual SSH
30
+ * session. The real one is `calls-sfu-ssh-client.ts`'s `connectCallsSfuSsh`
31
+ * (over `ssh2`); a host passes it as `ssh`. {@link CallsSfuConnectionPort} and
32
+ * {@link CallsSfuKeysPort} stay host-implemented on purpose: they read and
33
+ * write held keys (`@wtfalch/keys`), which only the host (archon) can reach,
34
+ * the same way `CoolifyPort`/`CloudflarePort`/`ZitadelPort`'s real clients
35
+ * live outside this package. `calls-sfu-ssh-adapter.ts`'s {@link CallsSfuSshSession} is the narrow shape
36
+ * a test fakes instead.
37
+ */
38
+ import { type LivekitCredentials } from '../calls-sfu-keys.js';
39
+ import { type CallsSfuSshSession } from '../calls-sfu-ssh-adapter.js';
40
+ /** The SFU VM's own address, SSH credentials and host key fingerprint -- held keys a human stored ahead of time. */
41
+ export interface CallsSfuConnection {
42
+ address: string;
43
+ sshUser: string;
44
+ sshPort: number;
45
+ sshPrivateKey: string;
46
+ /** `SHA256:...`, `hostKeyFingerprint`'s own format -- checked by `toCallsSfuHostPort`/`applyCallsSfuOverSession`. */
47
+ sshHostFingerprint: string;
48
+ /** #127's own held key, read back here rather than minted -- unrelated to the LiveKit pair this step mints. */
49
+ cloudflaredTunnelToken: string;
50
+ }
51
+ /**
52
+ * Read-only: `null` before a human has stored the SFU VM's connection
53
+ * details as held keys (#170's "Human steps"). This step never invents an
54
+ * address or a private key.
55
+ */
56
+ export interface CallsSfuConnectionPort {
57
+ resolveConnection(): Promise<CallsSfuConnection | null>;
58
+ }
59
+ /**
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
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`.
76
+ */
77
+ export interface CallsSfuKeysPort {
78
+ resolveLivekitKeys(orgSlug: string): Promise<LivekitCredentials | null>;
79
+ deliverLivekitKeys(orgSlug: string, pair: LivekitCredentials): Promise<void>;
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";
91
+ /** Opens the actual SSH session; `connectCallsSfuSsh` is the real implementation. */
92
+ export interface CallsSfuSshConnector {
93
+ connect(options: {
94
+ address: string;
95
+ sshUser: string;
96
+ sshPort: number;
97
+ sshPrivateKey: string;
98
+ /** Verified inside the handshake, before authentication; the connector refuses a mismatch. */
99
+ sshHostFingerprint: string;
100
+ }): Promise<CallsSfuSshSession & {
101
+ close(): void;
102
+ }>;
103
+ }
104
+ export declare class MissingCallsSfuConnectionError extends Error {
105
+ constructor();
106
+ }
107
+ export interface CallsSfuPlatformDeps {
108
+ connection: CallsSfuConnectionPort;
109
+ keys: CallsSfuKeysPort;
110
+ ssh: CallsSfuSshConnector;
111
+ /** Overridable so a test never needs a real CSPRNG call to assert on the minted pair. */
112
+ mintLivekitCredentials?: () => LivekitCredentials;
113
+ }
114
+ /**
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
125
+ * `applyCallsSfuOverSession` is itself idempotent -- so a second apply with
126
+ * the same held keys and the same org list changes nothing.
127
+ */
128
+ export declare function applyCallsSfuPlatformStep(deps: CallsSfuPlatformDeps, orgSlugs?: readonly string[]): Promise<void>;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The platform's `sfu` step (#170): the shared calls SFU (LiveKit +
3
+ * cloudflared, `../calls-sfu.ts`) applied from `applyPlatform` instead of by
4
+ * hand running `scripts/calls-sfu-deploy.mjs`. Mirrors #167's
5
+ * `platform-providers.ts` shape -- narrow ports, a host (archon) supplies the
6
+ * real implementation -- for the two things this step needs beyond
7
+ * `applyCallsSfu` itself:
8
+ *
9
+ * - {@link CallsSfuConnectionPort}: the SFU VM's address, SSH credentials and
10
+ * host key fingerprint. A human stores these as held keys ahead of time
11
+ * (#170's "Human steps") -- this package only ever reads them back, the
12
+ * same way #122's original script did from the environment.
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).
28
+ *
29
+ * {@link CallsSfuSshConnector} is the third port: opening the actual SSH
30
+ * session. The real one is `calls-sfu-ssh-client.ts`'s `connectCallsSfuSsh`
31
+ * (over `ssh2`); a host passes it as `ssh`. {@link CallsSfuConnectionPort} and
32
+ * {@link CallsSfuKeysPort} stay host-implemented on purpose: they read and
33
+ * write held keys (`@wtfalch/keys`), which only the host (archon) can reach,
34
+ * the same way `CoolifyPort`/`CloudflarePort`/`ZitadelPort`'s real clients
35
+ * live outside this package. `calls-sfu-ssh-adapter.ts`'s {@link CallsSfuSshSession} is the narrow shape
36
+ * a test fakes instead.
37
+ */
38
+ import { redactSecrets } from '../calls-sfu-deploy-support.js';
39
+ import { mintLivekitCredentials } from '../calls-sfu-keys.js';
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';
51
+ export class MissingCallsSfuConnectionError extends Error {
52
+ constructor() {
53
+ super('No SSH connection details are held for the calls SFU. Store the SFU VM’s ' +
54
+ 'SSH key and host key fingerprint as held keys before applying the ' +
55
+ 'platform’s sfu step (#170’s "Human steps").');
56
+ this.name = 'MissingCallsSfuConnectionError';
57
+ }
58
+ }
59
+ /**
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
70
+ * `applyCallsSfuOverSession` is itself idempotent -- so a second apply with
71
+ * the same held keys and the same org list changes nothing.
72
+ */
73
+ export async function applyCallsSfuPlatformStep(deps, orgSlugs = []) {
74
+ const connection = await deps.connection.resolveConnection();
75
+ if (!connection)
76
+ throw new MissingCallsSfuConnectionError();
77
+ // Every value below can echo back in an error (remote stdout/stderr, an
78
+ // ssh2 connect error), and the message reaches the platform audit row, so
79
+ // it is redacted before it leaves this function.
80
+ const secrets = [connection.sshPrivateKey, connection.cloudflaredTunnelToken];
81
+ try {
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;
108
+ }
109
+ const session = await deps.ssh.connect({
110
+ address: connection.address,
111
+ sshUser: connection.sshUser,
112
+ sshPort: connection.sshPort,
113
+ sshPrivateKey: connection.sshPrivateKey,
114
+ sshHostFingerprint: connection.sshHostFingerprint,
115
+ });
116
+ try {
117
+ const config = {
118
+ address: connection.address,
119
+ livekitKeys,
120
+ cloudflaredTunnelToken: connection.cloudflaredTunnelToken,
121
+ };
122
+ await applyCallsSfuOverSession(session, config, connection.sshHostFingerprint);
123
+ }
124
+ finally {
125
+ session.close();
126
+ }
127
+ }
128
+ catch (error) {
129
+ throw redactedError(error, secrets);
130
+ }
131
+ }
132
+ function redactedError(error, secrets) {
133
+ const original = error instanceof Error ? error : new Error(String(error));
134
+ const message = redactSecrets(original.message, secrets);
135
+ const stack = redactSecrets(original.stack ?? message, secrets);
136
+ if (message === original.message && stack === original.stack)
137
+ return original;
138
+ const clean = new Error(message);
139
+ clean.name = original.name;
140
+ clean.stack = stack;
141
+ return clean;
142
+ }
@@ -130,6 +130,26 @@ async function withLogVolumeConfig(client, resource, recorded) {
130
130
  return resource;
131
131
  }
132
132
  }
133
+ /**
134
+ * #164: the org's own recorded Agora app's live `AGORA_*` env vars, as
135
+ * `config.env`, so `diff.ts`'s `planAgora` can compare them with what
136
+ * `executor.ts` would write. Gated on `recorded` mapping this exact uuid onto
137
+ * the `agora` kind, like {@link withLogVolumeConfig}. Unlike that read, a
138
+ * failure here is not swallowed: a plan that cannot see the live config must
139
+ * fail, not report it as missing.
140
+ */
141
+ async function withAgoraEnvConfig(client, resource, recorded) {
142
+ if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('agora:')) {
143
+ return resource;
144
+ }
145
+ const envs = await client.listEnvs(resource.key);
146
+ const env = {};
147
+ for (const e of envs) {
148
+ if (!e.is_preview && e.key.startsWith('AGORA_'))
149
+ env[e.key] = e.value;
150
+ }
151
+ return { ...resource, config: { ...resource.config, env } };
152
+ }
133
153
  /** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
134
154
  function toFoundryResources(system, resources) {
135
155
  return resources.map((r) => ({ system, kind: r.kind, key: r.key, config: r.config }));
@@ -157,7 +177,8 @@ export class ProvidersCoolifyReader {
157
177
  this.resolveRecordedIds(orgId),
158
178
  ]);
159
179
  const withLogConfig = await Promise.all(toFoundryResources('coolify', resources).map((r) => withLogVolumeConfig(this.client, r, recorded)));
160
- return applyRecordedIds(withLogConfig, recorded);
180
+ const withAgoraConfig = await Promise.all(withLogConfig.map((r) => withAgoraEnvConfig(this.client, r, recorded)));
181
+ return applyRecordedIds(withAgoraConfig, recorded);
161
182
  }
162
183
  }
163
184
  /**
@@ -0,0 +1,83 @@
1
+ import type { ZitadelPort } from './executor.js';
2
+ /**
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.
11
+ *
12
+ * 1. `POST /management/v1/users/_search` finds the machine user by user name.
13
+ * 2. `POST /management/v1/users/machine` creates it when absent.
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.
44
+ *
45
+ * `token` is the bearer for the new instance, so it must belong to a caller
46
+ * allowed to create users and instance members (for example the instance's
47
+ * bootstrap machine user). The minted token is returned to the caller and
48
+ * never logged or put in an error.
49
+ */
50
+ export declare class ZitadelHttpError extends Error {
51
+ readonly status: number;
52
+ constructor(status: number, message: string);
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
+ }
75
+ export interface ZitadelMinterOptions {
76
+ /** The new instance's base URL, e.g. `https://auth.companyincluded.com`. */
77
+ url: string;
78
+ /** Bearer token allowed to create users and add instance members there. */
79
+ token: string;
80
+ fetchImpl?: typeof fetch;
81
+ instanceHost?: string;
82
+ }
83
+ export declare function createZitadelAdminMinter(options: ZitadelMinterOptions): Pick<ZitadelPort, 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats'>;
@@ -0,0 +1,238 @@
1
+ /**
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.
10
+ *
11
+ * 1. `POST /management/v1/users/_search` finds the machine user by user name.
12
+ * 2. `POST /management/v1/users/machine` creates it when absent.
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.
43
+ *
44
+ * `token` is the bearer for the new instance, so it must belong to a caller
45
+ * allowed to create users and instance members (for example the instance's
46
+ * bootstrap machine user). The minted token is returned to the caller and
47
+ * never logged or put in an error.
48
+ */
49
+ export class ZitadelHttpError extends Error {
50
+ status;
51
+ constructor(status, message) {
52
+ super(message);
53
+ this.status = status;
54
+ this.name = 'ZitadelHttpError';
55
+ }
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
+ }
83
+ export function createZitadelAdminMinter(options) {
84
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
85
+ async function call(method, path, body, orgId) {
86
+ const response = await fetchImpl(`${options.url}${path}`, {
87
+ method,
88
+ headers: {
89
+ authorization: `Bearer ${options.token}`,
90
+ 'content-type': 'application/json',
91
+ ...(options.instanceHost
92
+ ? {
93
+ 'x-zitadel-instance-host': options.instanceHost,
94
+ 'x-forwarded-host': options.instanceHost,
95
+ 'x-forwarded-proto': 'https',
96
+ }
97
+ : {}),
98
+ ...(orgId ? { 'x-zitadel-orgid': orgId } : {}),
99
+ },
100
+ body: body === undefined ? undefined : JSON.stringify(body),
101
+ });
102
+ const text = await response.text();
103
+ let data = {};
104
+ try {
105
+ data = text ? JSON.parse(text) : {};
106
+ }
107
+ catch {
108
+ // Never echo the body: on a PAT call it could hold the token.
109
+ throw new ZitadelHttpError(response.status, `non-JSON response from ${path}`);
110
+ }
111
+ if (!response.ok) {
112
+ throw new ZitadelHttpError(response.status, String(data.message ?? response.statusText));
113
+ }
114
+ return data;
115
+ }
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',
159
+ }, orgId);
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);
176
+ }
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
+ }
213
+ }
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),
237
+ };
238
+ }
package/dist/plan.d.ts CHANGED
@@ -35,6 +35,12 @@ export interface PlanStep {
35
35
  */
36
36
  export interface UnmanagedItem {
37
37
  description: string;
38
+ /**
39
+ * Set on a platform service that is blocked, not planned: the service's name
40
+ * (`auth`, `mail`, `integrations`, `files`, `ai`, `jobs`, `archon`, `sfu`).
41
+ * Absent on a live-but-undeclared resource.
42
+ */
43
+ key?: string;
38
44
  }
39
45
  /**
40
46
  * The diff between a {@link DeclaredOrg} and live reality, computed by the