@mesh-tech/mesh-cli 0.26.0 → 0.28.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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "8c1e961c4",
3
- "builtAt": "2026-09-17T07:12:18.568Z"
2
+ "commit": "dd1a85628",
3
+ "builtAt": "2026-09-17T22:16:24.040Z"
4
4
  }
@@ -23,7 +23,8 @@ export const APP_CHECK_RULES = {
23
23
  gate: "0.2",
24
24
  title: "App registers with the Hub",
25
25
  severity: "block",
26
- remediation: "end index.ts with `export const app = env.register({ description })`",
26
+ remediation: "end index.ts with `export const app = env.register({ displayName, description })` — " +
27
+ "one sentence a non-technical operator can act on; put the caveats in `operatorNote`",
27
28
  scope: "app",
28
29
  },
29
30
  CUSTOM_SESSION_AUTH: {
@@ -19,7 +19,9 @@ export interface HubDevEnvOptions {
19
19
  tenants?: string;
20
20
  uiPort: number;
21
21
  apiPort: number;
22
+ audience?: string;
22
23
  }
24
+ export declare function resolveHubAudience(raw: string | undefined): "operator" | "tenant";
23
25
  export interface AssembledHubEnv {
24
26
  apiEnv: Record<string, string>;
25
27
  uiEnv: Record<string, string>;
@@ -99,6 +99,11 @@ export function forwardedSessionVars(sessionEnv) {
99
99
  }
100
100
  return out;
101
101
  }
102
+ export function resolveHubAudience(raw) {
103
+ if (raw === undefined || raw === "")
104
+ return "operator";
105
+ return raw.trim().toLowerCase() === "operator" ? "operator" : "tenant";
106
+ }
102
107
  export function assembleHubEnv(session, sessionEnv, opts) {
103
108
  const warnings = [];
104
109
  const notes = [];
@@ -141,6 +146,7 @@ export function assembleHubEnv(session, sessionEnv, opts) {
141
146
  if (!temporalAddress) {
142
147
  throw new MeshCliError(`Dev session '${session.name}' exposes no Temporal tunnel — the Hub can't reach the stack's Temporal.`, { remediation: { command: "mesh dev # relaunch; the session records tunnels.temporal in its state" } });
143
148
  }
149
+ const audienceEnv = { HUB_AUDIENCE: resolveHubAudience(opts.audience) };
144
150
  const apiEnv = {
145
151
  ...forwarded,
146
152
  PORT: String(opts.apiPort),
@@ -148,6 +154,7 @@ export function assembleHubEnv(session, sessionEnv, opts) {
148
154
  HUB_TENANT: hubTenant,
149
155
  HUB_SCOPE_ENV: scopeEnv,
150
156
  HUB_SCOPE_TENANTS: scopeTenants.join(","),
157
+ ...audienceEnv,
151
158
  ...(isLocalPlatform ? localPlatformEnv(sessionEnv) : {}),
152
159
  };
153
160
  const uiEnv = {
@@ -157,6 +164,7 @@ export function assembleHubEnv(session, sessionEnv, opts) {
157
164
  HUB_TENANT: hubTenant,
158
165
  DEFAULT_ORG: scopeTenants[0],
159
166
  DEFAULT_ENV: scopeEnv,
167
+ ...audienceEnv,
160
168
  };
161
169
  return { apiEnv, uiEnv, scope: { hubTenant, scopeEnv, scopeTenants }, temporalAddress, warnings, notes };
162
170
  }
@@ -282,7 +290,12 @@ async function hubDevAction(opts) {
282
290
  throw new MeshCliError(`Dev session '${session.name}' has a state file but no live tmux session — its tunnels and token-server are gone.`, { remediation: { command: `mesh dev # from ${session.state.appRoot}` } });
283
291
  }
284
292
  const sessionEnv = readTmuxSessionEnv(session.name);
285
- const assembled = assembleHubEnv(session, sessionEnv, { tenants: opts.tenants, uiPort, apiPort });
293
+ const assembled = assembleHubEnv(session, sessionEnv, {
294
+ tenants: opts.tenants,
295
+ uiPort,
296
+ apiPort,
297
+ audience: opts.audience ?? process.env.HUB_AUDIENCE,
298
+ });
286
299
  if (opts.printEnv) {
287
300
  console.log(chalk.bold("\nhub-api env:"));
288
301
  for (const [k, v] of Object.entries(redactEnv(assembled.apiEnv)))
@@ -308,6 +321,7 @@ async function hubDevAction(opts) {
308
321
  logInfo(`Dev session : ${session.name} (${session.state.appRoot})`);
309
322
  logInfo(`Hub source : ${platformDir}`);
310
323
  logInfo(`Scope : HUB_TENANT=${assembled.scope.hubTenant} HUB_SCOPE_ENV=${assembled.scope.scopeEnv} HUB_SCOPE_TENANTS=${assembled.scope.scopeTenants.join(",")}`);
324
+ logInfo(`Audience : ${assembled.apiEnv.HUB_AUDIENCE} (--audience tenant for a client's view)`);
311
325
  logInfo(`Temporal : ${assembled.temporalAddress}`);
312
326
  for (const note of assembled.notes)
313
327
  logInfo(note);
@@ -372,6 +386,7 @@ export function registerHubCommands(program) {
372
386
  .description("Launch the current-code Hub (api + ui) against a running `mesh dev` session — env auto-assembled, zero hand-set vars")
373
387
  .option("--session <name>", "dev session to observe (default: auto-detect from cwd)")
374
388
  .option("--tenants <list>", "comma-separated HUB_SCOPE_TENANTS override (default: the session's tenant)")
389
+ .option("--audience <who>", "who the Hub renders for: `operator` (platform staff — the default here) or `tenant` (a client's operators). Anything else reads as `tenant`")
375
390
  .option("--port <port>", `Hub UI host port (default: $MESH_HUB_DEV_PORT or ${DEFAULT_HUB_UI_PORT})`)
376
391
  .option("--api-port <port>", `Hub API port (default: ${DEFAULT_HUB_API_PORT})`)
377
392
  .option("--platform-dir <dir>", "mesh-platform checkout to run the Hub from (default: $MESH_PLATFORM_DIR or walk up from cwd)")
@@ -1,10 +1,12 @@
1
1
  import { Command } from "commander";
2
- import { type ServiceStatus, type StackEndpoint } from "./stack.js";
2
+ import { type OverlayState, type ServiceStatus, type StackEndpoint } from "./stack.js";
3
3
  export interface WaitForStackIo {
4
4
  probe: (endpoint: StackEndpoint) => Promise<boolean>;
5
5
  services: () => ServiceStatus[];
6
- overlayServices: () => ReadonlySet<string>;
6
+ overlays: () => readonly OverlayState[];
7
7
  }
8
- export declare function waitForStack(io?: WaitForStackIo): Promise<void>;
8
+ export declare function waitForStack(opts: {
9
+ idpsStarted: boolean;
10
+ }, io?: WaitForStackIo): Promise<void>;
9
11
  export declare function startHereLines(hubRunning: boolean, up?: ReadonlyMap<string, boolean>): string[];
10
12
  export declare function registerLocalCommands(program: Command): void;
@@ -3,12 +3,13 @@ import * as path from "path";
3
3
  import chalk from "chalk";
4
4
  import { logError, logInfo, logSuccess, logWarn } from "../../utils/log.js";
5
5
  import { MeshCliError } from "../../utils/errors.js";
6
- import { compose, COMPOSE_PROJECT, composeStreamed, crashedServices, DEFAULT_HUB_PORT, ensureDockerAvailable, findPackageRoot, hubApiRunning, hubEndpoints, hubOverlayRunning, hubOverlayServices, hubPort, ONE_SHOT_SERVICES, probeEndpoint, STACK_ENDPOINTS, stackOwnedElsewhere, stackServices, } from "./stack.js";
6
+ import { compose, COMPOSE_PROJECT, composeStreamed, crashedServices, DEFAULT_HUB_PORT, ensureDockerAvailable, findPackageRoot, hubApiRunning, hubEndpoints, hubOverlayServices, hubPort, idpsOverlayServices, ONE_SHOT_SERVICES, overlayRunning, probeEndpoint, STACK_ENDPOINTS, stackOwnedElsewhere, stackServices, } from "./stack.js";
7
7
  import { probeRegistryToken, REGISTRY_LOGIN_FIX } from "../../utils/auth-preflight.js";
8
8
  import { ensureHubAuthImage, ensureHubImages, buildHubImagesFromSource, readHubCompiledAuthz, hasRegistryAuth, localHubVersion, planWithHubRefresh, } from "./hub-local.js";
9
9
  import { reconcileRegistryFromZitadel } from "./auth-provision.js";
10
10
  import { LOCAL_AWS_CONFIG, localAwsEnv, seedLocalPlatform, verifyFabric } from "./seed.js";
11
- import { LOGIN_CONTEXT, readHubAuth, seedZitadel, TEST_USERS } from "./seed-zitadel.js";
11
+ import { LOGIN_CONTEXT, readHubAuth, seedMockIdps, seedZitadel, TEST_USERS } from "./seed-zitadel.js";
12
+ import { idpEndpoints, mockIdpComposeEnv, MOCK_OIDC_PORT, MOCK_SAML_PORT } from "./mock-idps.js";
12
13
  import { publishHubAuthzCatalog } from "./seed-hub-catalog.js";
13
14
  import { describePortConflicts, findPortConflicts, hostPortsOf } from "./helpers.js";
14
15
  const WAIT_TIMEOUT_MS = 180_000;
@@ -20,10 +21,13 @@ function crashError(crashed) {
20
21
  },
21
22
  });
22
23
  }
23
- export async function waitForStack(io = {
24
+ export async function waitForStack(opts, io = {
24
25
  probe: probeEndpoint,
25
26
  services: stackServices,
26
- overlayServices: hubOverlayServices,
27
+ overlays: () => [
28
+ { services: hubOverlayServices(), started: false },
29
+ { services: idpsOverlayServices(), started: opts.idpsStarted },
30
+ ],
27
31
  }) {
28
32
  const deadline = Date.now() + WAIT_TIMEOUT_MS;
29
33
  let pending = STACK_ENDPOINTS.filter((e) => e.probe.kind !== "none");
@@ -39,10 +43,7 @@ export async function waitForStack(io = {
39
43
  }
40
44
  }
41
45
  pending = stillDown;
42
- const crashed = crashedServices(io.services(), {
43
- overlayServices: io.overlayServices(),
44
- overlayStarted: false,
45
- });
46
+ const crashed = crashedServices(io.services(), { overlays: io.overlays() });
46
47
  if (crashed.length > 0)
47
48
  throw crashError(crashed);
48
49
  if (pending.length === 0)
@@ -112,6 +113,11 @@ function portMoveHints() {
112
113
  Number(hubPort()),
113
114
  "MESH_HUB_PORT=<free port> mesh start (or mesh start --no-hub, which starts everything else)",
114
115
  ],
116
+ [MOCK_SAML_PORT, "mesh start (without --with-idps — the mock identity providers are opt-in)"],
117
+ [
118
+ MOCK_OIDC_PORT,
119
+ "mesh start (without --with-idps; 5556 is Dex's own default port, so a Dex of your own is the usual holder)",
120
+ ],
115
121
  ]);
116
122
  }
117
123
  function repoRootForHubSource() {
@@ -133,6 +139,7 @@ export function registerLocalCommands(program) {
133
139
  .option("--no-hub", "start without the Hub (API + UI)")
134
140
  .option("--with-hub", "force-refresh the Hub to the latest published @mesh-tech/hub (the Hub is included by default when its images exist or registry auth is available)")
135
141
  .option("--hub-from-source", "build the Hub images from THIS checkout's apps/hub instead of the published tarball (the only way to exercise a Hub change locally)")
142
+ .option("--with-idps", "also run mock SAML and OIDC identity providers, and federate the local Zitadel with both (for testing enterprise SSO without a cloud tenant or an IdP admin)", false)
136
143
  .option("--takeover", "re-own a stack that another checkout started (may recreate shared containers with THIS checkout's config)", false)
137
144
  .option("--skip-port-check", "start even though a host port the stack wants is already held (the service that loses the bind will be broken — use when the holder is something you cannot stop)", false)
138
145
  .action(async (opts) => {
@@ -151,7 +158,11 @@ export function registerLocalCommands(program) {
151
158
  logWarn(`Taking over the running stack (previously driven from ${foreign}).`);
152
159
  }
153
160
  if (!opts.skipPortCheck && !stackServices().some((s) => s.state === "running")) {
154
- const ports = hostPortsOf([...STACK_ENDPOINTS, ...(opts.hub ? hubEndpoints() : [])]);
161
+ const ports = hostPortsOf([
162
+ ...STACK_ENDPOINTS,
163
+ ...(opts.hub ? hubEndpoints() : []),
164
+ ...(opts.withIdps ? idpEndpoints() : []),
165
+ ]);
155
166
  const conflicts = await findPortConflicts(ports, `${COMPOSE_PROJECT}-`);
156
167
  if (conflicts.length > 0) {
157
168
  throw new MeshCliError(describePortConflicts(conflicts, portMoveHints()), {
@@ -197,9 +208,10 @@ export function registerLocalCommands(program) {
197
208
  }
198
209
  }
199
210
  logInfo("Starting the local Mesh platform (docker compose project: mesh-local)…");
200
- await composeStreamed(["up", "-d"]);
211
+ const idpEnv = opts.withIdps ? mockIdpComposeEnv() : undefined;
212
+ await composeStreamed(["up", "-d"], { idps: opts.withIdps, env: idpEnv });
201
213
  compose(["restart", "prometheus", "blackbox-exporter"], { inherit: true });
202
- await waitForStack();
214
+ await waitForStack({ idpsStarted: opts.withIdps });
203
215
  let hubAuth = readHubAuth();
204
216
  if (opts.seed) {
205
217
  logInfo("Seeding local platform (tenant registry, artifacts bucket)…");
@@ -207,6 +219,15 @@ export function registerLocalCommands(program) {
207
219
  logInfo("Seeding Zitadel (Platform project, Mesh CLI app, Hub auth, test users)…");
208
220
  const zitadel = await seedZitadel(LOCAL_AWS_CONFIG);
209
221
  hubAuth = zitadel.hubAuth;
222
+ if (opts.withIdps) {
223
+ logInfo("Federating local Zitadel with the mock SAML and OIDC identity providers…");
224
+ await seedMockIdps();
225
+ logInfo(`Mock SAML IdP: http://localhost:${MOCK_SAML_PORT} · ` +
226
+ `mock OIDC IdP: http://localhost:${MOCK_OIDC_PORT}/dex · ` +
227
+ "sign in at http://localhost:8080 and both federation buttons are offered");
228
+ logInfo("Both are listed in the Zitadel console under Settings → Identity Providers (owner: Instance): " +
229
+ "http://localhost:8080/ui/console/org-settings?id=idp");
230
+ }
210
231
  if (hubVersion) {
211
232
  const compiled = readHubCompiledAuthz(hubVersion);
212
233
  if (compiled) {
@@ -246,19 +267,23 @@ export function registerLocalCommands(program) {
246
267
  hubEnv.MESH_HUB_OAUTH2_CLIENT_SECRET = hubAuth.clientSecret;
247
268
  hubEnv.MESH_HUB_OAUTH2_COOKIE_SECRET = hubAuth.cookieSecret;
248
269
  }
249
- await composeStreamed(["up", "-d"], { hub: true, env: hubEnv });
270
+ await composeStreamed(["up", "-d"], {
271
+ hub: true,
272
+ idps: opts.withIdps,
273
+ env: { ...idpEnv, ...hubEnv },
274
+ });
250
275
  if (opts.seed) {
251
276
  compose(["restart", "hub-api"], { hub: true, env: hubEnv });
252
277
  }
253
- const overlayServices = hubOverlayServices();
278
+ const overlays = [
279
+ { services: hubOverlayServices(), started: true },
280
+ { services: idpsOverlayServices(), started: opts.withIdps },
281
+ ];
254
282
  for (const endpoint of hubEndpoints()) {
255
283
  logInfo(`Waiting for ${endpoint.label}…`);
256
284
  let up = await probeEndpoint(endpoint);
257
285
  for (let i = 0; i < 60 && !up; i++) {
258
- const crashed = crashedServices(stackServices(), {
259
- overlayServices,
260
- overlayStarted: true,
261
- });
286
+ const crashed = crashedServices(stackServices(), { overlays });
262
287
  if (crashed.length > 0)
263
288
  throw crashError(crashed);
264
289
  await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -310,11 +335,20 @@ export function registerLocalCommands(program) {
310
335
  remediation: { command: "mesh start" },
311
336
  });
312
337
  }
313
- const overlayServices = hubOverlayServices();
314
- const overlayStarted = hubOverlayRunning(services, overlayServices);
315
- const notStarted = (s) => !overlayStarted && overlayServices.has(s.name) && s.state === "exited";
338
+ const hubOverlay = hubOverlayServices();
339
+ const idpsOverlay = idpsOverlayServices();
340
+ const idpsStarted = overlayRunning(services, idpsOverlay);
341
+ const overlays = [
342
+ { services: hubOverlay, started: overlayRunning(services, hubOverlay) },
343
+ { services: idpsOverlay, started: idpsStarted },
344
+ ];
345
+ const notStarted = (s) => s.state === "exited" && overlays.some((o) => !o.started && o.services.has(s.name));
316
346
  const hasHub = hubApiRunning(services);
317
- const endpoints = hasHub ? [...STACK_ENDPOINTS, ...hubEndpoints()] : STACK_ENDPOINTS;
347
+ const endpoints = [
348
+ ...STACK_ENDPOINTS,
349
+ ...(hasHub ? hubEndpoints() : []),
350
+ ...(idpsStarted ? idpEndpoints() : []),
351
+ ];
318
352
  const probes = await Promise.all(endpoints.map(async (endpoint) => ({
319
353
  service: endpoint.service,
320
354
  label: endpoint.label,
@@ -337,6 +371,7 @@ export function registerLocalCommands(program) {
337
371
  services,
338
372
  endpoints: probes,
339
373
  fabric,
374
+ mockIdps: idpsStarted,
340
375
  awsEnv: localAwsEnv(),
341
376
  }));
342
377
  if (fabric && !fabric.ok)
@@ -352,7 +387,7 @@ export function registerLocalCommands(program) {
352
387
  const idle = notStarted(service);
353
388
  const icon = ok || oneShotDone ? chalk.green("●") : idle ? chalk.dim("○") : chalk.red("●");
354
389
  const health = service.health ? ` (${service.health})` : "";
355
- const note = idle ? chalk.dim(" (Hub not started this run)") : "";
390
+ const note = idle ? chalk.dim(" (not started this run)") : "";
356
391
  console.log(` ${icon} ${service.name.padEnd(22)} ${service.state}${health}${note}`);
357
392
  }
358
393
  console.log("");
@@ -366,8 +401,11 @@ export function registerLocalCommands(program) {
366
401
  const detail = fabric.ok ? "8 KB Advanced-tier round-trip OK" : fabric.error;
367
402
  console.log(` ${icon} ${"AWS fabric check".padEnd(34)} ${chalk.dim(detail ?? "")}`);
368
403
  }
404
+ if (!idpsStarted) {
405
+ console.log(` ${chalk.dim("○")} ${"Mock IdPs (SAML + OIDC)".padEnd(34)} ${chalk.dim("not started — mesh start --with-idps")}`);
406
+ }
369
407
  const down = probes.filter((p) => !p.up);
370
- const crashed = crashedServices(services, { overlayServices, overlayStarted });
408
+ const crashed = crashedServices(services, { overlays });
371
409
  if (fabric && !fabric.ok)
372
410
  process.exitCode = 1;
373
411
  if (down.length > 0 || crashed.length > 0) {
@@ -0,0 +1,48 @@
1
+ import type { StackEndpoint } from "./stack.js";
2
+ export declare const MOCK_SAML_PORT = 4569;
3
+ export declare const MOCK_OIDC_PORT = 5556;
4
+ export declare const idpEndpoints: () => StackEndpoint[];
5
+ export declare const MOCK_SAML_IDP_NAME = "Mock SAML (local)";
6
+ export declare const MOCK_OIDC_IDP_NAME = "Mock OIDC (local)";
7
+ export declare const mockSamlMetadataUrl: () => string;
8
+ export declare const mockOidcEndpoints: () => {
9
+ authorization: string;
10
+ token: string;
11
+ user: string;
12
+ };
13
+ export declare function ensureMockSamlSigningKey(): {
14
+ publicKeyB64: string;
15
+ privateKeyB64: string;
16
+ };
17
+ export declare const mockIdpComposeEnv: () => Record<string, string>;
18
+ export declare const buildMockSamlIdpBody: (metadataXml: string) => {
19
+ name: string;
20
+ metadataXml: string;
21
+ binding: string;
22
+ withSignedRequest: boolean;
23
+ providerOptions: {
24
+ isLinkingAllowed: boolean;
25
+ isCreationAllowed: boolean;
26
+ isAutoCreation: boolean;
27
+ isAutoUpdate: boolean;
28
+ autoLinking: string;
29
+ };
30
+ };
31
+ export declare const buildMockOauthIdpBody: () => {
32
+ name: string;
33
+ clientId: string;
34
+ clientSecret: string;
35
+ authorizationEndpoint: string;
36
+ tokenEndpoint: string;
37
+ userEndpoint: string;
38
+ scopes: string[];
39
+ idAttribute: string;
40
+ providerOptions: {
41
+ isLinkingAllowed: boolean;
42
+ isCreationAllowed: boolean;
43
+ isAutoCreation: boolean;
44
+ isAutoUpdate: boolean;
45
+ autoLinking: string;
46
+ };
47
+ };
48
+ export declare const missingLoginPolicyIdps: (attached: readonly string[], wanted: readonly string[]) => string[];
@@ -0,0 +1,119 @@
1
+ import { execFileSync } from "child_process";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { meshCacheDir } from "../../utils/cache-home.js";
5
+ import { MeshCliError } from "../../utils/errors.js";
6
+ export const MOCK_SAML_PORT = 4569;
7
+ export const MOCK_OIDC_PORT = 5556;
8
+ const MOCK_OIDC_SERVICE = "mock-oidc";
9
+ export const idpEndpoints = () => [
10
+ {
11
+ service: "mock-saml",
12
+ label: "Mock SAML IdP",
13
+ url: `http://localhost:${MOCK_SAML_PORT}`,
14
+ probe: { kind: "http", url: mockSamlMetadataUrl() },
15
+ hint: "sign in as any email you type; federation metadata at /api/saml/metadata",
16
+ },
17
+ {
18
+ service: MOCK_OIDC_SERVICE,
19
+ label: "Mock OIDC IdP",
20
+ url: `http://localhost:${MOCK_OIDC_PORT}/dex`,
21
+ probe: {
22
+ kind: "http",
23
+ url: `http://localhost:${MOCK_OIDC_PORT}/dex/.well-known/openid-configuration`,
24
+ },
25
+ hint: "dev@local.mesh or ops@local.mesh / password",
26
+ },
27
+ ];
28
+ export const MOCK_SAML_IDP_NAME = "Mock SAML (local)";
29
+ export const MOCK_OIDC_IDP_NAME = "Mock OIDC (local)";
30
+ const MOCK_OIDC_CLIENT_ID = "mesh-local";
31
+ const MOCK_OIDC_CLIENT_SECRET = "mesh-local-secret";
32
+ export const mockSamlMetadataUrl = () => `http://localhost:${MOCK_SAML_PORT}/api/saml/metadata`;
33
+ export const mockOidcEndpoints = () => ({
34
+ authorization: `http://localhost:${MOCK_OIDC_PORT}/dex/auth`,
35
+ token: `http://${MOCK_OIDC_SERVICE}:${MOCK_OIDC_PORT}/dex/token`,
36
+ user: `http://${MOCK_OIDC_SERVICE}:${MOCK_OIDC_PORT}/dex/userinfo`,
37
+ });
38
+ const keyDir = () => path.join(meshCacheDir("mesh-local"), "mock-idp");
39
+ export function ensureMockSamlSigningKey() {
40
+ const dir = keyDir();
41
+ const certPath = path.join(dir, "cert.pem");
42
+ const keyPath = path.join(dir, "key.pem");
43
+ if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) {
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ try {
46
+ execFileSync("openssl", [
47
+ "req",
48
+ "-x509",
49
+ "-newkey",
50
+ "rsa:2048",
51
+ "-keyout",
52
+ keyPath,
53
+ "-out",
54
+ certPath,
55
+ "-days",
56
+ "3650",
57
+ "-nodes",
58
+ "-subj",
59
+ "/CN=mesh-local-mock-saml",
60
+ ], { stdio: "ignore" });
61
+ }
62
+ catch (cause) {
63
+ throw new MeshCliError("Could not generate the mock SAML signing key.", {
64
+ remediation: {
65
+ command: "openssl version # openssl must be on PATH for --with-idps",
66
+ },
67
+ cause,
68
+ });
69
+ }
70
+ }
71
+ return {
72
+ publicKeyB64: fs.readFileSync(certPath).toString("base64"),
73
+ privateKeyB64: fs.readFileSync(keyPath).toString("base64"),
74
+ };
75
+ }
76
+ export const mockIdpComposeEnv = () => {
77
+ const { publicKeyB64, privateKeyB64 } = ensureMockSamlSigningKey();
78
+ return {
79
+ MESH_MOCK_SAML_PUBLIC_KEY: publicKeyB64,
80
+ MESH_MOCK_SAML_PRIVATE_KEY: privateKeyB64,
81
+ };
82
+ };
83
+ export const buildMockSamlIdpBody = (metadataXml) => ({
84
+ name: MOCK_SAML_IDP_NAME,
85
+ metadataXml: Buffer.from(metadataXml, "utf-8").toString("base64"),
86
+ binding: "SAML_BINDING_POST",
87
+ withSignedRequest: true,
88
+ providerOptions: {
89
+ isLinkingAllowed: true,
90
+ isCreationAllowed: true,
91
+ isAutoCreation: true,
92
+ isAutoUpdate: true,
93
+ autoLinking: "AUTO_LINKING_OPTION_EMAIL",
94
+ },
95
+ });
96
+ export const buildMockOauthIdpBody = () => {
97
+ const endpoints = mockOidcEndpoints();
98
+ return {
99
+ name: MOCK_OIDC_IDP_NAME,
100
+ clientId: MOCK_OIDC_CLIENT_ID,
101
+ clientSecret: MOCK_OIDC_CLIENT_SECRET,
102
+ authorizationEndpoint: endpoints.authorization,
103
+ tokenEndpoint: endpoints.token,
104
+ userEndpoint: endpoints.user,
105
+ scopes: ["openid", "profile", "email"],
106
+ idAttribute: "sub",
107
+ providerOptions: {
108
+ isLinkingAllowed: true,
109
+ isCreationAllowed: true,
110
+ isAutoCreation: true,
111
+ isAutoUpdate: true,
112
+ autoLinking: "AUTO_LINKING_OPTION_EMAIL",
113
+ },
114
+ };
115
+ };
116
+ export const missingLoginPolicyIdps = (attached, wanted) => {
117
+ const have = new Set(attached);
118
+ return [...new Set(wanted)].filter((id) => !have.has(id));
119
+ };
@@ -73,4 +73,10 @@ export interface ZitadelSeedResult {
73
73
  hubAuth: HubAuthConfig;
74
74
  }
75
75
  export declare function seedZitadel(awsConfig: object): Promise<ZitadelSeedResult>;
76
+ export declare const IDP_SEARCH_PATH = "/admin/v1/idps/templates/_search";
77
+ export declare const pickIdpIdByName: (searchResult: any, name: string) => string | undefined;
78
+ export declare function seedMockIdps(): Promise<{
79
+ samlIdpId: string;
80
+ oidcIdpId: string;
81
+ }>;
76
82
  export {};
@@ -8,6 +8,7 @@ import { writeContextConfig } from "../login.js";
8
8
  import { compose, hubPort, DEFAULT_HUB_PORT } from "./stack.js";
9
9
  import { LOCAL_TENANT, LOCAL_ENV } from "./seed.js";
10
10
  import { meshCacheDir } from "../../utils/cache-home.js";
11
+ import { buildMockOauthIdpBody, buildMockSamlIdpBody, missingLoginPolicyIdps, MOCK_OIDC_IDP_NAME, MOCK_SAML_IDP_NAME, mockSamlMetadataUrl, } from "./mock-idps.js";
11
12
  export const ZITADEL_ISSUER = "http://localhost:8080";
12
13
  export const LOGIN_CONTEXT = "local";
13
14
  export const PLATFORM_ORG = "mesh";
@@ -441,3 +442,78 @@ export async function seedZitadel(awsConfig) {
441
442
  logSuccess(`Login context '${LOGIN_CONTEXT}' configured → try: mesh login ${LOGIN_CONTEXT}`);
442
443
  return { projectId, cliClientId, hubAuth };
443
444
  }
445
+ async function fetchMockSamlMetadata() {
446
+ const url = mockSamlMetadataUrl();
447
+ let lastError = "no attempt made";
448
+ for (let attempt = 0; attempt < 30; attempt++) {
449
+ try {
450
+ const res = await fetch(url, { signal: AbortSignal.timeout(5_000) });
451
+ const body = await res.text();
452
+ if (res.ok && body.includes("EntityDescriptor")) {
453
+ if (/<ds:X509Certificate\s*\/>|<ds:X509Certificate>\s*<\/ds:X509Certificate>/.test(body)) {
454
+ throw new MeshCliError("The mock SAML IdP served metadata with an empty signing certificate.", {
455
+ remediation: {
456
+ command: "rm -rf ~/.cache/mesh/mesh-local/mock-idp && mesh stop && mesh start --with-idps",
457
+ },
458
+ });
459
+ }
460
+ return body;
461
+ }
462
+ lastError = `HTTP ${res.status}`;
463
+ }
464
+ catch (cause) {
465
+ if (cause instanceof MeshCliError)
466
+ throw cause;
467
+ lastError = String(cause);
468
+ }
469
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
470
+ }
471
+ throw new MeshCliError(`Could not read mock SAML metadata from ${url} (${lastError}).`, {
472
+ remediation: { command: "mesh status # is mock-saml running?" },
473
+ });
474
+ }
475
+ async function attachedLoginPolicyIdps(pat) {
476
+ const res = await api(pat, "POST", "/admin/v1/policies/login/idps/_search", {});
477
+ return (res?.result ?? []).map((idp) => String(idp.idpId ?? idp.id)).filter(Boolean);
478
+ }
479
+ export const IDP_SEARCH_PATH = "/admin/v1/idps/templates/_search";
480
+ export const pickIdpIdByName = (searchResult, name) => {
481
+ const hit = (searchResult?.result ?? []).find((idp) => idp?.name === name);
482
+ return hit?.id ? String(hit.id) : undefined;
483
+ };
484
+ async function findIdpByName(pat, name) {
485
+ return pickIdpIdByName(await api(pat, "POST", IDP_SEARCH_PATH, {}), name);
486
+ }
487
+ export async function seedMockIdps() {
488
+ const pat = readSeederPat();
489
+ const metadataXml = await fetchMockSamlMetadata();
490
+ let samlIdpId = await findIdpByName(pat, MOCK_SAML_IDP_NAME);
491
+ if (!samlIdpId) {
492
+ const created = await api(pat, "POST", "/admin/v1/idps/saml", buildMockSamlIdpBody(metadataXml));
493
+ samlIdpId = String(created.id);
494
+ }
495
+ logSuccess(`Mock SAML IdP ready (${MOCK_SAML_IDP_NAME}, id ${samlIdpId})`);
496
+ let oidcIdpId = await findIdpByName(pat, MOCK_OIDC_IDP_NAME);
497
+ if (!oidcIdpId) {
498
+ const created = await api(pat, "POST", "/admin/v1/idps/oauth", buildMockOauthIdpBody());
499
+ oidcIdpId = String(created.id);
500
+ }
501
+ logSuccess(`Mock OIDC IdP ready (${MOCK_OIDC_IDP_NAME}, id ${oidcIdpId})`);
502
+ const policy = await api(pat, "GET", "/admin/v1/policies/login");
503
+ if (policy?.policy?.allowExternalIdp !== true) {
504
+ await api(pat, "PUT", "/admin/v1/policies/login", {
505
+ ...policy.policy,
506
+ allowExternalIdp: true,
507
+ });
508
+ logInfo("Instance login policy: allowExternalIdp turned on (federation buttons were hidden)");
509
+ }
510
+ const attached = await attachedLoginPolicyIdps(pat);
511
+ const toAttach = missingLoginPolicyIdps(attached, [samlIdpId, oidcIdpId]);
512
+ for (const idpId of toAttach) {
513
+ await api(pat, "POST", "/admin/v1/policies/login/idps", { idpId, ownerType: "IDP_OWNER_TYPE_SYSTEM" });
514
+ }
515
+ const finalIdps = await attachedLoginPolicyIdps(pat);
516
+ logSuccess(`Login policy carries ${finalIdps.length} federation(s) — both mocks attached, ` +
517
+ "which is the state a login-policy change has to preserve");
518
+ return { samlIdpId, oidcIdpId };
519
+ }
@@ -29,20 +29,25 @@ export declare function hubPort(): string;
29
29
  export declare function hubEndpoints(): StackEndpoint[];
30
30
  export declare function findPackageRoot(startDir?: string): string;
31
31
  export declare function stackDir(): string;
32
+ export declare const BASE_COMPOSE_FILE = "docker-compose.yml";
32
33
  export declare const HUB_OVERLAY_FILE = "docker-compose.hub.yml";
34
+ export declare const IDPS_OVERLAY_FILE = "docker-compose.idps.yml";
33
35
  export declare function parseComposeServiceNames(yamlText: string): string[];
34
36
  export declare function hubOverlayServices(): ReadonlySet<string>;
37
+ export declare function idpsOverlayServices(): ReadonlySet<string>;
35
38
  export declare function stackOwnedElsewhere(): string | undefined;
36
39
  export declare function ensureDockerAvailable(): void;
37
40
  export declare function compose(args: string[], opts?: {
38
41
  inherit?: boolean;
39
42
  hub?: boolean;
43
+ idps?: boolean;
40
44
  env?: Record<string, string>;
41
45
  }): string;
42
46
  export declare function summarizeComposeFailure(output: string): string;
43
47
  export declare function composeLogPath(op: string): string;
44
48
  export declare function composeStreamed(args: string[], opts?: {
45
49
  hub?: boolean;
50
+ idps?: boolean;
46
51
  env?: Record<string, string>;
47
52
  }): Promise<void>;
48
53
  export interface ServiceStatus {
@@ -53,10 +58,13 @@ export interface ServiceStatus {
53
58
  export declare function parseComposePs(output: string): ServiceStatus[];
54
59
  export declare function stackServices(): ServiceStatus[];
55
60
  export declare const ONE_SHOT_SERVICES: Set<string>;
61
+ export interface OverlayState {
62
+ services: ReadonlySet<string>;
63
+ started: boolean;
64
+ }
56
65
  export declare function crashedServices(services: ServiceStatus[], opts: {
57
- overlayServices: ReadonlySet<string>;
58
- overlayStarted: boolean;
66
+ overlays: readonly OverlayState[];
59
67
  }): ServiceStatus[];
60
- export declare function hubOverlayRunning(services: ServiceStatus[], overlayServices: ReadonlySet<string>): boolean;
68
+ export declare function overlayRunning(services: ServiceStatus[], overlayServices: ReadonlySet<string>): boolean;
61
69
  export declare function hubApiRunning(services: ServiceStatus[]): boolean;
62
70
  export declare function probeEndpoint(endpoint: StackEndpoint): Promise<boolean>;